diff --git a/packages/overlay/src/LongpressController.ts b/packages/overlay/src/LongpressController.ts index 8cf38bb6d76..dd854613a29 100644 --- a/packages/overlay/src/LongpressController.ts +++ b/packages/overlay/src/LongpressController.ts @@ -56,24 +56,49 @@ 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; + if (!this.target || event.button !== 0) return; + + const triggerHandlesLongpress = + 'holdAffordance' in this.target && + (this.target as HTMLElement & { holdAffordance: boolean }) + .holdAffordance === true; + 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; + + // 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, composed: true, - detail: { - source: 'pointer', - }, + detail: { source: 'pointer' }, }) ); }, LONGPRESS_DURATION); @@ -81,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 { diff --git a/packages/overlay/stories/overlay.stories.ts b/packages/overlay/stories/overlay.stories.ts index 06074def5b9..88a550b4079 100644 --- a/packages/overlay/stories/overlay.stories.ts +++ b/packages/overlay/stories/overlay.stories.ts @@ -1820,3 +1820,192 @@ 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 + overlay +

+
    +
  1. + Long press + (click and hold for 300ms) the "Trigger Overlay" button + below +
  2. +
  3. An overlay will open
  4. +
  5. + Close the overlay 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 overlay closes. +
+ Bug (fixed): + Without the fix, all buttons would become unresponsive after + the longpress overlay. +

+
+ +
+
Longpress to open overlay:
+ + + Trigger Overlay (Long Press) + + + + + + Orientation + Left/Right + Top/Bottom + + + + +
+ +
+
+ Test these buttons after closing the overlay: +
+ + Click me + + + Quiet button + + + Emphasized button + + + Selected button + + + Regular button + + + Accent button + +
+ +
+ ✓ Fix applied: LongpressController uses capture phase for event + listeners +
+ + + + Options + + + + + Orientation + Left/Right + Top/Bottom + + + + +
+ `; +}; + +LongpressModalResponsiveness.args = { + chromatic: { disableSnapshot: true }, +}; +LongpressModalResponsiveness.parameters = { + tags: ['!dev'], +}; +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; + }); });