Skip to content

Commit 8e9965f

Browse files
author
Shipra Gupta
committed
fix: handle touch device submenu interactions
1 parent 7f9549f commit 8e9965f

File tree

1 file changed

+42
-117
lines changed

1 file changed

+42
-117
lines changed

packages/menu/src/MenuItem.ts

Lines changed: 42 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import {
1414
CSSResultArray,
1515
html,
16-
INPUT_COMPONENT_PATTERN,
1716
nothing,
1817
PropertyValues,
1918
TemplateResult,
@@ -181,6 +180,8 @@ export class MenuItem extends LikeAnchor(
181180
return this._value || this.itemText;
182181
}
183182

183+
private _lastPointerType?: string;
184+
184185
public set value(value: string) {
185186
if (value === this._value) {
186187
return;
@@ -457,124 +458,15 @@ export class MenuItem extends LikeAnchor(
457458
}
458459
}
459460

460-
private handlePointerdown(event: PointerEvent): void {
461-
if (event.target === this && this.hasSubmenu && this.open) {
462-
this.addEventListener('focus', this.handleSubmenuFocus, {
463-
once: true,
464-
});
465-
this.overlayElement.addEventListener(
466-
'beforetoggle',
467-
this.handleBeforetoggle
468-
);
469-
}
470-
}
471-
472461
protected override firstUpdated(changes: PropertyValues): void {
473462
super.firstUpdated(changes);
474463
this.setAttribute('tabindex', '-1');
475464
this.addEventListener('keydown', this.handleKeydown);
476-
this.addEventListener('mouseover', this.handleMouseover);
477-
this.addEventListener('pointerdown', this.handlePointerdown);
478-
this.addEventListener('pointerenter', this.closeOverlaysForRoot);
479465
if (!this.hasAttribute('id')) {
480466
this.id = `sp-menu-item-${randomID()}`;
481467
}
482468
}
483469

484-
private getActiveElementSafely(): HTMLElement | null {
485-
let root = this.getRootNode() as Document | ShadowRoot;
486-
let activeElement = root.activeElement as HTMLElement;
487-
488-
// If no active element in current context and we're in shadow DOM,
489-
// traverse up to find the document-level active element
490-
if (!activeElement && root !== document) {
491-
while (root && root !== document && 'host' in root) {
492-
root = (root as ShadowRoot).host.getRootNode() as
493-
| Document
494-
| ShadowRoot;
495-
activeElement = root.activeElement as HTMLElement;
496-
if (activeElement) break;
497-
}
498-
}
499-
500-
return activeElement;
501-
}
502-
503-
handleMouseover(event: MouseEvent): void {
504-
const target = event.target as HTMLElement;
505-
if (target === this) {
506-
// Check for active input elements across shadow boundaries
507-
const activeElement = this.getActiveElementSafely();
508-
509-
// Only focus this menu item if no input element is currently active
510-
// This prevents interrupting user input in search boxes, text fields, etc.
511-
if (!activeElement || !this.isInputElement(activeElement)) {
512-
this.focus();
513-
}
514-
this.focused = false;
515-
}
516-
}
517-
518-
/**
519-
* Determines if an element is an input field that should retain focus.
520-
* Uses multiple detection strategies to identify input elements generically.
521-
*/
522-
private isInputElement(element: HTMLElement): boolean {
523-
// Check for native HTML input elements
524-
if (this.isNativeInputElement(element)) {
525-
return true;
526-
}
527-
528-
// Check for contenteditable elements (rich text editors)
529-
if (element.contentEditable === 'true') {
530-
return true;
531-
}
532-
533-
// Check for Spectrum Web Components with input-like behavior
534-
if (this.isSpectrumInputComponent(element)) {
535-
return true;
536-
}
537-
538-
return false;
539-
}
540-
541-
/**
542-
* Checks if an element is a native HTML input element.
543-
*/
544-
private isNativeInputElement(element: HTMLElement): boolean {
545-
return (
546-
element instanceof HTMLInputElement ||
547-
element instanceof HTMLTextAreaElement ||
548-
element instanceof HTMLSelectElement
549-
);
550-
}
551-
552-
/**
553-
* Checks if an element is a Spectrum Web Component with input behavior.
554-
* Uses ARIA roles and component patterns for generic detection.
555-
*/
556-
private isSpectrumInputComponent(element: HTMLElement): boolean {
557-
// Check if it's a Spectrum Web Component
558-
if (!element.tagName.startsWith('SP-')) {
559-
return false;
560-
}
561-
562-
// Check ARIA role for input-like behavior
563-
const role = element.getAttribute('role');
564-
const inputRoles = ['textbox', 'searchbox', 'combobox', 'slider'];
565-
if (role && inputRoles.includes(role)) {
566-
return true;
567-
}
568-
569-
// Check for components that typically contain input elements
570-
// This covers components like sp-search, sp-textfield, sp-number-field, etc.
571-
const inputComponentPattern = INPUT_COMPONENT_PATTERN;
572-
if (inputComponentPattern.test(element.tagName)) {
573-
return true;
574-
}
575-
576-
return false;
577-
}
578470
/**
579471
* forward key info from keydown event to parent menu
580472
*/
@@ -595,8 +487,8 @@ export class MenuItem extends LikeAnchor(
595487
};
596488

597489
protected closeOverlaysForRoot(): void {
598-
if (this.open) return;
599-
this.menuData.parentMenu?.closeDescendentOverlays();
490+
// if (this.open) return;
491+
// this.menuData.parentMenu?.closeDescendentOverlays();
600492
}
601493

602494
protected handleFocus(event: FocusEvent): void {
@@ -613,11 +505,25 @@ export class MenuItem extends LikeAnchor(
613505
}
614506
}
615507

616-
protected handleSubmenuClick(event: Event): void {
508+
protected handleSubmenuTriggerClick(event: Event): void {
617509
if (event.composedPath().includes(this.overlayElement)) {
618510
return;
619511
}
620-
this.openOverlay(true);
512+
513+
// If submenu is already open, toggle it closed
514+
if (this.open && this._lastPointerType === 'touch') {
515+
event.preventDefault();
516+
event.stopPropagation(); // Don't let parent menu handle this
517+
this.open = false;
518+
return;
519+
}
520+
521+
// All: open if closed
522+
if (!this.open) {
523+
event.preventDefault();
524+
event.stopImmediatePropagation();
525+
this.openOverlay(true);
526+
}
621527
}
622528

623529
protected handleSubmenuFocus(): void {
@@ -640,7 +546,19 @@ export class MenuItem extends LikeAnchor(
640546
}
641547
};
642548

643-
protected handlePointerenter(): void {
549+
protected handlePointerenter(event: PointerEvent): void {
550+
this._lastPointerType = event.pointerType; // Track pointer type
551+
552+
// For touch: don't handle pointerenter, let click handle it
553+
if (event.pointerType === 'touch') {
554+
return;
555+
}
556+
557+
// Close other submenus (from closeOverlaysForRoot)
558+
if (!this.open) {
559+
this.menuData.parentMenu?.closeDescendentOverlays();
560+
}
561+
644562
if (this.leaveTimeout) {
645563
clearTimeout(this.leaveTimeout);
646564
delete this.leaveTimeout;
@@ -654,7 +572,14 @@ export class MenuItem extends LikeAnchor(
654572
protected leaveTimeout?: ReturnType<typeof setTimeout>;
655573
protected recentlyLeftChild = false;
656574

657-
protected handlePointerleave(): void {
575+
protected handlePointerleave(event: PointerEvent): void {
576+
this._lastPointerType = event.pointerType; // Update on leave too
577+
578+
// For touch: don't handle pointerleave, let click handle it
579+
if (event.pointerType === 'touch') {
580+
return;
581+
}
582+
658583
this._closedViaPointer = true;
659584
if (this.open && !this.recentlyLeftChild) {
660585
this.leaveTimeout = setTimeout(() => {
@@ -782,7 +707,7 @@ export class MenuItem extends LikeAnchor(
782707
const options = { signal: this.abortControllerSubmenu.signal };
783708
this.addEventListener(
784709
'click',
785-
this.handleSubmenuClick,
710+
this.handleSubmenuTriggerClick,
786711
options
787712
);
788713
this.addEventListener(

0 commit comments

Comments
 (0)