Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 42 additions & 16 deletions packages/overlay/src/LongpressController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,40 +56,66 @@ 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<LongpressEvent>('longpress', {
bubbles: true,
composed: true,
detail: {
source: 'pointer',
},
detail: { source: 'pointer' },
})
);
}, LONGPRESS_DURATION);
}

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 {
Expand Down
189 changes: 189 additions & 0 deletions packages/overlay/stories/overlay.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1820,3 +1820,192 @@ export const WithInteractiveContent = (): TemplateResult => {
</div>
`;
};

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}
<style>
.demo-container {
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px;
max-width: 600px;
}

.instructions {
background: var(--spectrum-gray-100);
padding: 16px;
border-radius: 4px;
font-size: 14px;
line-height: 1.5;
}

.instructions h3 {
margin-top: 0;
color: var(--spectrum-heading-color);
}

.instructions ol {
margin: 8px 0;
padding-left: 20px;
}

.instructions li {
margin: 4px 0;
}

.button-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 16px;
background: var(--spectrum-gray-50);
border-radius: 4px;
}

.button-group-label {
width: 100%;
font-weight: bold;
margin-bottom: 8px;
color: var(--spectrum-heading-color);
}

.status {
padding: 12px;
background: var(--spectrum-positive-background-color-default);
color: var(--spectrum-positive-content-color-default);
border-radius: 4px;
font-weight: 500;
}
</style>

<div class="demo-container">
<div class="instructions">
<h3>
Test: Action buttons remain responsive after longpress
overlay
</h3>
<ol>
<li>
<strong>Long press</strong>
(click and hold for 300ms) the "Trigger Overlay" button
below
</li>
<li>An overlay will open</li>
<li>
Close the overlay by clicking "Close" or pressing Escape
</li>
<li>
Click any of the test buttons below - they should all
respond normally
</li>
</ol>
<p>
<strong>Expected:</strong>
All buttons remain clickable after the overlay closes.
<br />
<strong>Bug (fixed):</strong>
Without the fix, all buttons would become unresponsive after
the longpress overlay.
</p>
</div>

<div class="button-group">
<div class="button-group-label">Longpress to open overlay:</div>
<sp-action-button id="longpress-trigger" hold-affordance>
<sp-icon-magnify slot="icon"></sp-icon-magnify>
Trigger Overlay (Long Press)
</sp-action-button>
<sp-overlay
trigger="longpress-trigger@longpress"
type="modal"
placement="right-start"
>
<sp-popover>
<sp-menu>
<sp-menu-group selects="single" size="s">
<span slot="header">Orientation</span>
<sp-menu-item>Left/Right</sp-menu-item>
<sp-menu-item>Top/Bottom</sp-menu-item>
</sp-menu-group>
</sp-menu>
</sp-popover>
</sp-overlay>
</div>

<div class="button-group">
<div class="button-group-label">
Test these buttons after closing the overlay:
</div>
<sp-action-button @click=${handleClick} data-clicks="0">
Click me
</sp-action-button>
<sp-action-button @click=${handleClick} data-clicks="0" quiet>
Quiet button
</sp-action-button>
<sp-action-button
@click=${handleClick}
data-clicks="0"
emphasized
>
Emphasized button
</sp-action-button>
<sp-action-button
@click=${handleClick}
data-clicks="0"
selected
>
Selected button
</sp-action-button>
<sp-button @click=${handleClick} data-clicks="0">
Regular button
</sp-button>
<sp-button
@click=${handleClick}
data-clicks="0"
variant="accent"
>
Accent button
</sp-button>
</div>

<div class="status">
✓ Fix applied: LongpressController uses capture phase for event
listeners
</div>

<overlay-trigger triggered-by="longpress" placement="right-start">
<sp-action-button slot="trigger" hold-affordance>
Options
</sp-action-button>
<sp-popover slot="longpress-content">
<sp-menu>
<sp-menu-group selects="single" size="s">
<span slot="header">Orientation</span>
<sp-menu-item>Left/Right</sp-menu-item>
<sp-menu-item>Top/Bottom</sp-menu-item>
</sp-menu-group>
</sp-menu>
</sp-popover>
</overlay-trigger>
</div>
`;
};

LongpressModalResponsiveness.args = {
chromatic: { disableSnapshot: true },
};
LongpressModalResponsiveness.parameters = {
tags: ['!dev'],
};
LongpressModalResponsiveness.swc_vrt = {
skip: true,
};
50 changes: 50 additions & 0 deletions packages/overlay/test/overlay-trigger-longpress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`
<div>
<sp-action-button id="trigger" hold-affordance>
Trigger Modal (Long Press)
</sp-action-button>
<sp-overlay
id="overlay"
trigger="trigger@longpress"
type="modal"
>
<sp-popover>
<sp-menu>
<sp-menu-item>Option 1</sp-menu-item>
</sp-menu>
</sp-popover>
</sp-overlay>
<sp-action-button id="other">Another Button</sp-action-button>
</div>
`);

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;
});
});
Loading