Skip to content
5 changes: 5 additions & 0 deletions .changeset/brave-foxes-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@spectrum-web-components/menu': patch
---

**Fixed**: Improved touch interaction handling for submenus to prevent unintended submenu closures.
77 changes: 74 additions & 3 deletions 1st-gen/packages/menu/src/MenuItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ export class MenuItem extends LikeAnchor(
return this._value || this.itemText;
}

private _lastPointerType?: string;

public set value(value: string) {
if (value === this._value) {
return;
Expand Down Expand Up @@ -457,8 +459,34 @@ export class MenuItem extends LikeAnchor(
}
}

private _touchListenerActive = false;

private handlePointerdown(event: PointerEvent): void {
if (event.target === this && this.hasSubmenu && this.open) {
// Track pointer type for touch detection
this._lastPointerType = event.pointerType;

// For touch devices with submenus, handle on pointerup instead of click
// Only if the touch is directly on this menu item (not on overlay or child elements)
if (
event.pointerType === 'touch' &&
this.hasSubmenu &&
event.target === this &&
!this._touchListenerActive
) {
event.preventDefault(); // Prevent click suppression
event.stopPropagation(); // Prevent bubbling to parent menu items
this._touchListenerActive = true;
this.addEventListener('pointerup', this.handleTouchSubmenuToggle, {
once: true,
});
}

if (
event.target === this &&
this.hasSubmenu &&
this.open &&
event.pointerType !== 'touch'
) {
this.addEventListener('focus', this.handleSubmenuFocus, {
once: true,
});
Expand All @@ -469,6 +497,21 @@ export class MenuItem extends LikeAnchor(
}
}

private handleTouchSubmenuToggle = (event: PointerEvent): void => {
event.preventDefault();
event.stopPropagation();

// Reset the listener flag
this._touchListenerActive = false;

// Toggle the submenu
if (this.open) {
this.open = false;
} else {
this.openOverlay(true);
}
};

protected override firstUpdated(changes: PropertyValues): void {
super.firstUpdated(changes);
this.setAttribute('tabindex', '-1');
Expand Down Expand Up @@ -614,9 +657,23 @@ export class MenuItem extends LikeAnchor(
}

protected handleSubmenuClick(event: Event): void {
const pointerEvent = event as PointerEvent;

const isTouchEvent =
pointerEvent.pointerType === 'touch' ||
this._lastPointerType === 'touch';

// For touch events, completely ignore click
if (isTouchEvent) {
event.stopPropagation();
event.preventDefault();
return;
}

if (event.composedPath().includes(this.overlayElement)) {
return;
}

this.openOverlay(true);
}

Expand All @@ -640,7 +697,14 @@ export class MenuItem extends LikeAnchor(
}
};

protected handlePointerenter(): void {
protected handlePointerenter(event: PointerEvent): void {
this._lastPointerType = event.pointerType;

// For touch devices, don't open on pointerenter - let click handle it
if (event.pointerType === 'touch') {
return;
}

if (this.leaveTimeout) {
clearTimeout(this.leaveTimeout);
delete this.leaveTimeout;
Expand All @@ -654,7 +718,14 @@ export class MenuItem extends LikeAnchor(
protected leaveTimeout?: ReturnType<typeof setTimeout>;
protected recentlyLeftChild = false;

protected handlePointerleave(): void {
protected handlePointerleave(event: PointerEvent): void {
this._lastPointerType = event.pointerType;

// For touch devices, don't close on pointerleave - let click handle it
if (event.pointerType === 'touch') {
return;
}

this._closedViaPointer = true;
if (this.open && !this.recentlyLeftChild) {
this.leaveTimeout = setTimeout(() => {
Expand Down
Loading
Loading