From 2d8e95b7bc34fd1ad44561639937fe58090f83aa Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Tue, 7 Oct 2025 12:31:42 +0530 Subject: [PATCH 1/5] fix(overlay): handles the pointerdown event to initiate a potential long press --- packages/overlay/src/LongpressController.ts | 26 ++- packages/overlay/stories/overlay.stories.ts | 167 ++++++++++++++++++ .../test/overlay-trigger-longpress.test.ts | 50 ++++++ 3 files changed, 235 insertions(+), 8 deletions(-) diff --git a/packages/overlay/src/LongpressController.ts b/packages/overlay/src/LongpressController.ts index 8cf38bb6d76..beb5427ddf6 100644 --- a/packages/overlay/src/LongpressController.ts +++ b/packages/overlay/src/LongpressController.ts @@ -59,21 +59,31 @@ export class LongpressController extends InteractionController { handlePointerdown(event: PointerEvent): void { if (!this.target) return; if (event.button !== 0) return; - this.longpressState = 'potential'; - document.addEventListener('pointerup', this.handlePointerup); - document.addEventListener('pointercancel', this.handlePointerup); - // Only dispatch longpress event if the trigger element isn't doing it for us. - const triggerHandlesLongpress = 'holdAffordance' in this.target; + + const triggerHandlesLongpress = + 'holdAffordance' in this.target && + (this.target as HTMLElement & { holdAffordance: boolean }) + .holdAffordance === true; if (triggerHandlesLongpress) return; + + this.longpressState = 'potential'; + this.target.addEventListener('pointerup', this.handlePointerup, { + once: true, + }); + this.target.addEventListener('pointercancel', this.handlePointerup, { + once: true, + }); + this.timeout = setTimeout(() => { if (!this.target) return; + (this.target as HTMLElement).releasePointerCapture?.( + event.pointerId + ); this.target.dispatchEvent( new CustomEvent('longpress', { bubbles: true, composed: true, - detail: { - source: 'pointer', - }, + detail: { source: 'pointer' }, }) ); }, LONGPRESS_DURATION); diff --git a/packages/overlay/stories/overlay.stories.ts b/packages/overlay/stories/overlay.stories.ts index 06074def5b9..be80fc1f253 100644 --- a/packages/overlay/stories/overlay.stories.ts +++ b/packages/overlay/stories/overlay.stories.ts @@ -1820,3 +1820,170 @@ export const WithInteractiveContent = (): TemplateResult => { `; }; + +export const LongpressModalResponsiveness = (): TemplateResult => { + const handleClick = (event: Event): void => { + const button = event.target as HTMLElement; + const clickCount = parseInt(button.getAttribute('data-clicks') || '0'); + button.setAttribute('data-clicks', String(clickCount + 1)); + button.textContent = `Clicked ${clickCount + 1} time${clickCount === 0 ? '' : 's'}`; + }; + + return html` + ${storyStyles} + + +
+
+

+ Test: Action buttons remain responsive after longpress modal +

+
    +
  1. + Long press + (click and hold for 300ms) the "Trigger Modal" button + below +
  2. +
  3. A modal dialog will open
  4. +
  5. + Close the modal by clicking "Close" or pressing Escape +
  6. +
  7. + Click any of the test buttons below - they should all + respond normally +
  8. +
+

+ Expected: + All buttons remain clickable after the modal closes. +
+ Bug (fixed): + Without the fix, all buttons would become unresponsive after + the longpress modal. +

+
+ +
+
Longpress to open modal:
+ + + Trigger Modal (Long Press) + + + + + + Orientation + Left/Right + Top/Bottom + + + + +
+ +
+
+ Test these buttons after closing the modal: +
+ + Click me + + + Quiet button + + + Emphasized button + + + Selected button + + + Regular button + + + Accent button + +
+ +
+ ✓ Fix applied: LongpressController uses capture phase for event + listeners +
+
+ `; +}; + +LongpressModalResponsiveness.swc_vrt = { + skip: true, +}; diff --git a/packages/overlay/test/overlay-trigger-longpress.test.ts b/packages/overlay/test/overlay-trigger-longpress.test.ts index d3f7a648538..2b82261b0a4 100644 --- a/packages/overlay/test/overlay-trigger-longpress.test.ts +++ b/packages/overlay/test/overlay-trigger-longpress.test.ts @@ -23,8 +23,11 @@ import '@spectrum-web-components/action-button/sp-action-button.js'; import '@spectrum-web-components/action-group/sp-action-group.js'; import '@spectrum-web-components/button/sp-button.js'; import '@spectrum-web-components/icons-workflow/icons/sp-icon-magnify.js'; +import '@spectrum-web-components/menu/sp-menu.js'; +import '@spectrum-web-components/menu/sp-menu-item.js'; import { LONGPRESS_INSTRUCTIONS, + Overlay, OverlayTrigger, } from '@spectrum-web-components/overlay'; import '@spectrum-web-components/overlay/overlay-trigger.js'; @@ -557,4 +560,51 @@ describe('Overlay Trigger - Longpress', () => { LONGPRESS_INSTRUCTIONS.keyboard ); }); + it('keeps other buttons responsive after longpress overlay opens', async () => { + const el = await fixture(html` +
+ + Trigger Modal (Long Press) + + + + + Option 1 + + + + Another Button +
+ `); + + const trigger = el.querySelector('#trigger') as ActionButton; + const otherButton = el.querySelector('#other') as ActionButton; + const overlay = el.querySelector('#overlay') as Overlay; + + // Simulate long press on trigger + const { x, y } = trigger.getBoundingClientRect(); + await sendMouse({ type: 'move', position: [x + 5, y + 5] }); + await sendMouse({ type: 'down' }); + await new Promise((r) => setTimeout(r, 350)); // > LONGPRESS_DURATION (300ms) + await sendMouse({ type: 'up' }); + + // Wait for overlay to open + await oneEvent(overlay, 'sp-opened'); + + // Try clicking the other button after overlay open + let clicked = false; + otherButton.addEventListener('click', () => (clicked = true)); + + const { x: bx, y: by } = otherButton.getBoundingClientRect(); + await sendMouse({ type: 'move', position: [bx + 5, by + 5] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'up' }); + + // ✅ Assert that the other button is still clickable + expect(clicked).to.be.true; + }); }); From 1504524f3d737cbf80be72d2be312494b78b7bfa Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Tue, 7 Oct 2025 13:32:01 +0530 Subject: [PATCH 2/5] chore: updated story --- packages/overlay/stories/overlay.stories.ts | 25 +++++++++++++-------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/overlay/stories/overlay.stories.ts b/packages/overlay/stories/overlay.stories.ts index be80fc1f253..06351e94df0 100644 --- a/packages/overlay/stories/overlay.stories.ts +++ b/packages/overlay/stories/overlay.stories.ts @@ -1890,17 +1890,18 @@ export const LongpressModalResponsiveness = (): TemplateResult => {

- Test: Action buttons remain responsive after longpress modal + Test: Action buttons remain responsive after longpress + overlay

  1. Long press - (click and hold for 300ms) the "Trigger Modal" button + (click and hold for 300ms) the "Trigger Overlay" button below
  2. -
  3. A modal dialog will open
  4. +
  5. An overlay will open
  6. - Close the modal by clicking "Close" or pressing Escape + Close the overlay by clicking "Close" or pressing Escape
  7. Click any of the test buttons below - they should all @@ -1909,19 +1910,19 @@ export const LongpressModalResponsiveness = (): TemplateResult => {

Expected: - All buttons remain clickable after the modal closes. + All buttons remain clickable after the overlay closes.
Bug (fixed): Without the fix, all buttons would become unresponsive after - the longpress modal. + the longpress overlay.

-
Longpress to open modal:
+
Longpress to open overlay:
- Trigger Modal (Long Press) + Trigger Overlay (Long Press) {
- Test these buttons after closing the modal: + Test these buttons after closing the overlay:
Click me @@ -1984,6 +1985,12 @@ export const LongpressModalResponsiveness = (): TemplateResult => { `; }; +LongpressModalResponsiveness.args = { + chromatic: { disableSnapshot: true }, +}; +LongpressModalResponsiveness.parameters = { + tags: ['!dev'], +}; LongpressModalResponsiveness.swc_vrt = { skip: true, }; From 5ba2cf3e6133db315894d58a729f35ce7407bc4c Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Tue, 7 Oct 2025 13:33:35 +0530 Subject: [PATCH 3/5] fix: check if target element handles longpress internally --- packages/overlay/src/LongpressController.ts | 32 ++++++++++++--------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/overlay/src/LongpressController.ts b/packages/overlay/src/LongpressController.ts index beb5427ddf6..f411584feeb 100644 --- a/packages/overlay/src/LongpressController.ts +++ b/packages/overlay/src/LongpressController.ts @@ -56,29 +56,35 @@ export class LongpressController extends InteractionController { this.longpressState === 'potential' ? 'opening' : 'pressed'; } + /** + * Handles pointer down events to initiate longpress detection. + * + * This method sets up the longpress interaction by: + * 1. Setting the longpress state to 'potential' + * 2. Adding document-level event listeners for pointerup and pointercancel + * 3. Checking if the target element handles longpress internally (holdAffordance) + * 4. Setting a timeout to dispatch the longpress event after LONGPRESS_DURATION + * + * Note: Document-level listeners are used instead of target-level listeners + * to ensure proper event handling across overlay boundaries and to prevent + * interference with other pointer interactions. + * + * @param event - The pointer down event that triggered this handler + */ handlePointerdown(event: PointerEvent): void { if (!this.target) return; if (event.button !== 0) return; - + this.longpressState = 'potential'; + document.addEventListener('pointerup', this.handlePointerup); + document.addEventListener('pointercancel', this.handlePointerup); + // Only dispatch longpress event if the trigger element isn't doing it for us. const triggerHandlesLongpress = 'holdAffordance' in this.target && (this.target as HTMLElement & { holdAffordance: boolean }) .holdAffordance === true; if (triggerHandlesLongpress) return; - - this.longpressState = 'potential'; - this.target.addEventListener('pointerup', this.handlePointerup, { - once: true, - }); - this.target.addEventListener('pointercancel', this.handlePointerup, { - once: true, - }); - this.timeout = setTimeout(() => { if (!this.target) return; - (this.target as HTMLElement).releasePointerCapture?.( - event.pointerId - ); this.target.dispatchEvent( new CustomEvent('longpress', { bubbles: true, From 5944a02230b48c3ef36878224436fe030e92324d Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Tue, 7 Oct 2025 14:56:29 +0530 Subject: [PATCH 4/5] fix: longpress controller releasePointerCapture deferred --- packages/overlay/src/LongpressController.ts | 36 +++++++++++++-------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/overlay/src/LongpressController.ts b/packages/overlay/src/LongpressController.ts index f411584feeb..dd854613a29 100644 --- a/packages/overlay/src/LongpressController.ts +++ b/packages/overlay/src/LongpressController.ts @@ -72,19 +72,28 @@ export class LongpressController extends InteractionController { * @param event - The pointer down event that triggered this handler */ handlePointerdown(event: PointerEvent): void { - if (!this.target) return; - if (event.button !== 0) return; - this.longpressState = 'potential'; - document.addEventListener('pointerup', this.handlePointerup); - document.addEventListener('pointercancel', this.handlePointerup); - // Only dispatch longpress event if the trigger element isn't doing it for us. + if (!this.target || event.button !== 0) return; + const triggerHandlesLongpress = 'holdAffordance' in this.target && (this.target as HTMLElement & { holdAffordance: boolean }) .holdAffordance === true; + + this.longpressState = 'potential'; + + // If the element already handles longpress, do not dispatch manually if (triggerHandlesLongpress) return; + + this.target.addEventListener('pointerup', this.handlePointerup, { + once: true, + }); + this.target.addEventListener('pointercancel', this.handlePointerup, { + once: true, + }); + this.timeout = setTimeout(() => { if (!this.target) return; + this.target.dispatchEvent( new CustomEvent('longpress', { bubbles: true, @@ -97,15 +106,16 @@ export class LongpressController extends InteractionController { private handlePointerup = (): void => { clearTimeout(this.timeout); + if (!this.target) return; - // When triggered by the pointer, the last of `opened` - // or `pointerup` should move the `longpressState` to - // `null` so that the earlier event can void the "light - // dismiss" and keep the Overlay open. + + // Maintain overlay open state if it was opened by longpress this.longpressState = - this.overlay?.state === 'opening' ? 'pressed' : null; - document.removeEventListener('pointerup', this.handlePointerup); - document.removeEventListener('pointercancel', this.handlePointerup); + this.overlay?.state === 'opening' || this.overlay?.open + ? 'pressed' + : null; + + // No global listener cleanup needed (we used { once: true }) }; private handleKeydown(event: KeyboardEvent): void { From c6b6abc945dc81e4aaac2c7248c9d53e65f5ad98 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Wed, 8 Oct 2025 02:02:07 +0530 Subject: [PATCH 5/5] chore: added a overlay trigger story with action button hldaffordance --- packages/overlay/stories/overlay.stories.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/overlay/stories/overlay.stories.ts b/packages/overlay/stories/overlay.stories.ts index 06351e94df0..88a550b4079 100644 --- a/packages/overlay/stories/overlay.stories.ts +++ b/packages/overlay/stories/overlay.stories.ts @@ -1981,6 +1981,21 @@ export const LongpressModalResponsiveness = (): TemplateResult => { ✓ Fix applied: LongpressController uses capture phase for event listeners
+ + + + Options + + + + + Orientation + Left/Right + Top/Bottom + + + +
`; };