From 8e9965fd2ae088dff5c6e11714347d231e31f4ed Mon Sep 17 00:00:00 2001 From: Shipra Gupta Date: Tue, 21 Oct 2025 14:10:08 -0700 Subject: [PATCH 1/5] fix: handle touch device submenu interactions --- packages/menu/src/MenuItem.ts | 159 +++++++++------------------------- 1 file changed, 42 insertions(+), 117 deletions(-) diff --git a/packages/menu/src/MenuItem.ts b/packages/menu/src/MenuItem.ts index 75de1e0de3..ea9f4af768 100644 --- a/packages/menu/src/MenuItem.ts +++ b/packages/menu/src/MenuItem.ts @@ -13,7 +13,6 @@ import { CSSResultArray, html, - INPUT_COMPONENT_PATTERN, nothing, PropertyValues, TemplateResult, @@ -181,6 +180,8 @@ export class MenuItem extends LikeAnchor( return this._value || this.itemText; } + private _lastPointerType?: string; + public set value(value: string) { if (value === this._value) { return; @@ -457,124 +458,15 @@ export class MenuItem extends LikeAnchor( } } - private handlePointerdown(event: PointerEvent): void { - if (event.target === this && this.hasSubmenu && this.open) { - this.addEventListener('focus', this.handleSubmenuFocus, { - once: true, - }); - this.overlayElement.addEventListener( - 'beforetoggle', - this.handleBeforetoggle - ); - } - } - protected override firstUpdated(changes: PropertyValues): void { super.firstUpdated(changes); this.setAttribute('tabindex', '-1'); this.addEventListener('keydown', this.handleKeydown); - this.addEventListener('mouseover', this.handleMouseover); - this.addEventListener('pointerdown', this.handlePointerdown); - this.addEventListener('pointerenter', this.closeOverlaysForRoot); if (!this.hasAttribute('id')) { this.id = `sp-menu-item-${randomID()}`; } } - private getActiveElementSafely(): HTMLElement | null { - let root = this.getRootNode() as Document | ShadowRoot; - let activeElement = root.activeElement as HTMLElement; - - // If no active element in current context and we're in shadow DOM, - // traverse up to find the document-level active element - if (!activeElement && root !== document) { - while (root && root !== document && 'host' in root) { - root = (root as ShadowRoot).host.getRootNode() as - | Document - | ShadowRoot; - activeElement = root.activeElement as HTMLElement; - if (activeElement) break; - } - } - - return activeElement; - } - - handleMouseover(event: MouseEvent): void { - const target = event.target as HTMLElement; - if (target === this) { - // Check for active input elements across shadow boundaries - const activeElement = this.getActiveElementSafely(); - - // Only focus this menu item if no input element is currently active - // This prevents interrupting user input in search boxes, text fields, etc. - if (!activeElement || !this.isInputElement(activeElement)) { - this.focus(); - } - this.focused = false; - } - } - - /** - * Determines if an element is an input field that should retain focus. - * Uses multiple detection strategies to identify input elements generically. - */ - private isInputElement(element: HTMLElement): boolean { - // Check for native HTML input elements - if (this.isNativeInputElement(element)) { - return true; - } - - // Check for contenteditable elements (rich text editors) - if (element.contentEditable === 'true') { - return true; - } - - // Check for Spectrum Web Components with input-like behavior - if (this.isSpectrumInputComponent(element)) { - return true; - } - - return false; - } - - /** - * Checks if an element is a native HTML input element. - */ - private isNativeInputElement(element: HTMLElement): boolean { - return ( - element instanceof HTMLInputElement || - element instanceof HTMLTextAreaElement || - element instanceof HTMLSelectElement - ); - } - - /** - * Checks if an element is a Spectrum Web Component with input behavior. - * Uses ARIA roles and component patterns for generic detection. - */ - private isSpectrumInputComponent(element: HTMLElement): boolean { - // Check if it's a Spectrum Web Component - if (!element.tagName.startsWith('SP-')) { - return false; - } - - // Check ARIA role for input-like behavior - const role = element.getAttribute('role'); - const inputRoles = ['textbox', 'searchbox', 'combobox', 'slider']; - if (role && inputRoles.includes(role)) { - return true; - } - - // Check for components that typically contain input elements - // This covers components like sp-search, sp-textfield, sp-number-field, etc. - const inputComponentPattern = INPUT_COMPONENT_PATTERN; - if (inputComponentPattern.test(element.tagName)) { - return true; - } - - return false; - } /** * forward key info from keydown event to parent menu */ @@ -595,8 +487,8 @@ export class MenuItem extends LikeAnchor( }; protected closeOverlaysForRoot(): void { - if (this.open) return; - this.menuData.parentMenu?.closeDescendentOverlays(); + // if (this.open) return; + // this.menuData.parentMenu?.closeDescendentOverlays(); } protected handleFocus(event: FocusEvent): void { @@ -613,11 +505,25 @@ export class MenuItem extends LikeAnchor( } } - protected handleSubmenuClick(event: Event): void { + protected handleSubmenuTriggerClick(event: Event): void { if (event.composedPath().includes(this.overlayElement)) { return; } - this.openOverlay(true); + + // If submenu is already open, toggle it closed + if (this.open && this._lastPointerType === 'touch') { + event.preventDefault(); + event.stopPropagation(); // Don't let parent menu handle this + this.open = false; + return; + } + + // All: open if closed + if (!this.open) { + event.preventDefault(); + event.stopImmediatePropagation(); + this.openOverlay(true); + } } protected handleSubmenuFocus(): void { @@ -640,7 +546,19 @@ export class MenuItem extends LikeAnchor( } }; - protected handlePointerenter(): void { + protected handlePointerenter(event: PointerEvent): void { + this._lastPointerType = event.pointerType; // Track pointer type + + // For touch: don't handle pointerenter, let click handle it + if (event.pointerType === 'touch') { + return; + } + + // Close other submenus (from closeOverlaysForRoot) + if (!this.open) { + this.menuData.parentMenu?.closeDescendentOverlays(); + } + if (this.leaveTimeout) { clearTimeout(this.leaveTimeout); delete this.leaveTimeout; @@ -654,7 +572,14 @@ export class MenuItem extends LikeAnchor( protected leaveTimeout?: ReturnType; protected recentlyLeftChild = false; - protected handlePointerleave(): void { + protected handlePointerleave(event: PointerEvent): void { + this._lastPointerType = event.pointerType; // Update on leave too + + // For touch: don't handle pointerleave, let click handle it + if (event.pointerType === 'touch') { + return; + } + this._closedViaPointer = true; if (this.open && !this.recentlyLeftChild) { this.leaveTimeout = setTimeout(() => { @@ -782,7 +707,7 @@ export class MenuItem extends LikeAnchor( const options = { signal: this.abortControllerSubmenu.signal }; this.addEventListener( 'click', - this.handleSubmenuClick, + this.handleSubmenuTriggerClick, options ); this.addEventListener( From 0fb732cb6b10a6a954f3d545837439f22b4daa63 Mon Sep 17 00:00:00 2001 From: Shipra Gupta Date: Tue, 21 Oct 2025 16:20:37 -0700 Subject: [PATCH 2/5] fix: improve touch device submenu interactions --- packages/menu/src/MenuItem.ts | 104 ++++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 6 deletions(-) diff --git a/packages/menu/src/MenuItem.ts b/packages/menu/src/MenuItem.ts index ea9f4af768..58f37f65ef 100644 --- a/packages/menu/src/MenuItem.ts +++ b/packages/menu/src/MenuItem.ts @@ -13,6 +13,7 @@ import { CSSResultArray, html, + INPUT_COMPONENT_PATTERN, nothing, PropertyValues, TemplateResult, @@ -462,11 +463,107 @@ export class MenuItem extends LikeAnchor( super.firstUpdated(changes); this.setAttribute('tabindex', '-1'); this.addEventListener('keydown', this.handleKeydown); + this.addEventListener('mouseover', this.handleMouseover); if (!this.hasAttribute('id')) { this.id = `sp-menu-item-${randomID()}`; } } + private getActiveElementSafely(): HTMLElement | null { + let root = this.getRootNode() as Document | ShadowRoot; + let activeElement = root.activeElement as HTMLElement; + + // If no active element in current context and we're in shadow DOM, + // traverse up to find the document-level active element + if (!activeElement && root !== document) { + while (root && root !== document && 'host' in root) { + root = (root as ShadowRoot).host.getRootNode() as + | Document + | ShadowRoot; + activeElement = root.activeElement as HTMLElement; + if (activeElement) break; + } + } + + return activeElement; + } + + handleMouseover(event: MouseEvent): void { + const target = event.target as HTMLElement; + if (target === this) { + // Check for active input elements across shadow boundaries + const activeElement = this.getActiveElementSafely(); + + // Only focus this menu item if no input element is currently active + // This prevents interrupting user input in search boxes, text fields, etc. + if (!activeElement || !this.isInputElement(activeElement)) { + this.focus(); + } + this.focused = false; + } + } + + /** + * Determines if an element is an input field that should retain focus. + * Uses multiple detection strategies to identify input elements generically. + */ + private isInputElement(element: HTMLElement): boolean { + // Check for native HTML input elements + if (this.isNativeInputElement(element)) { + return true; + } + + // Check for contenteditable elements (rich text editors) + if (element.contentEditable === 'true') { + return true; + } + + // Check for Spectrum Web Components with input-like behavior + if (this.isSpectrumInputComponent(element)) { + return true; + } + + return false; + } + + /** + * Checks if an element is a native HTML input element. + */ + private isNativeInputElement(element: HTMLElement): boolean { + return ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement + ); + } + + /** + * Checks if an element is a Spectrum Web Component with input behavior. + * Uses ARIA roles and component patterns for generic detection. + */ + private isSpectrumInputComponent(element: HTMLElement): boolean { + // Check if it's a Spectrum Web Component + if (!element.tagName.startsWith('SP-')) { + return false; + } + + // Check ARIA role for input-like behavior + const role = element.getAttribute('role'); + const inputRoles = ['textbox', 'searchbox', 'combobox', 'slider']; + if (role && inputRoles.includes(role)) { + return true; + } + + // Check for components that typically contain input elements + // This covers components like sp-search, sp-textfield, sp-number-field, etc. + const inputComponentPattern = INPUT_COMPONENT_PATTERN; + if (inputComponentPattern.test(element.tagName)) { + return true; + } + + return false; + } + /** * forward key info from keydown event to parent menu */ @@ -486,11 +583,6 @@ export class MenuItem extends LikeAnchor( } }; - protected closeOverlaysForRoot(): void { - // if (this.open) return; - // this.menuData.parentMenu?.closeDescendentOverlays(); - } - protected handleFocus(event: FocusEvent): void { const { target } = event; if (target === this) { @@ -554,7 +646,7 @@ export class MenuItem extends LikeAnchor( return; } - // Close other submenus (from closeOverlaysForRoot) + // Close other submenus if (!this.open) { this.menuData.parentMenu?.closeDescendentOverlays(); } From 8b7aa06247aa3bb1842dc440b9053b21e8df2e39 Mon Sep 17 00:00:00 2001 From: Shipra Gupta Date: Tue, 21 Oct 2025 16:55:59 -0700 Subject: [PATCH 3/5] fix: improve touch device submenu interactions --- packages/menu/src/MenuItem.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/packages/menu/src/MenuItem.ts b/packages/menu/src/MenuItem.ts index 58f37f65ef..c03c60d50b 100644 --- a/packages/menu/src/MenuItem.ts +++ b/packages/menu/src/MenuItem.ts @@ -618,26 +618,6 @@ export class MenuItem extends LikeAnchor( } } - protected handleSubmenuFocus(): void { - requestAnimationFrame(() => { - // Wait till after `closeDescendentOverlays` has happened in Menu - // to reopen (keep open) the direct descendent of this Menu Item - this.overlayElement.open = this.open; - this.focused = false; - }); - } - - protected handleBeforetoggle = (event: Event): void => { - if ((event as Event & { newState: string }).newState === 'closed') { - this.open = true; - this.overlayElement.manuallyKeepOpen(); - this.overlayElement.removeEventListener( - 'beforetoggle', - this.handleBeforetoggle - ); - } - }; - protected handlePointerenter(event: PointerEvent): void { this._lastPointerType = event.pointerType; // Track pointer type From 50539127ce747af1b708796b2574d2af72f7fc84 Mon Sep 17 00:00:00 2001 From: Shipra Gupta Date: Wed, 22 Oct 2025 12:05:10 -0700 Subject: [PATCH 4/5] fix: failed tests --- packages/menu/src/MenuItem.ts | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/menu/src/MenuItem.ts b/packages/menu/src/MenuItem.ts index c03c60d50b..4450c23ef3 100644 --- a/packages/menu/src/MenuItem.ts +++ b/packages/menu/src/MenuItem.ts @@ -464,6 +464,10 @@ export class MenuItem extends LikeAnchor( this.setAttribute('tabindex', '-1'); this.addEventListener('keydown', this.handleKeydown); this.addEventListener('mouseover', this.handleMouseover); + // Register pointerenter/leave for ALL menu items (not just those with submenus) + // so items without submenus can close sibling submenus when hovered + this.addEventListener('pointerenter', this.handlePointerenter); + this.addEventListener('pointerleave', this.handlePointerleave); if (!this.hasAttribute('id')) { this.id = `sp-menu-item-${randomID()}`; } @@ -626,10 +630,8 @@ export class MenuItem extends LikeAnchor( return; } - // Close other submenus - if (!this.open) { - this.menuData.parentMenu?.closeDescendentOverlays(); - } + // Close sibling submenus before opening this one + this.menuData.parentMenu?.closeDescendentOverlays(); if (this.leaveTimeout) { clearTimeout(this.leaveTimeout); @@ -637,7 +639,12 @@ export class MenuItem extends LikeAnchor( this.recentlyLeftChild = false; return; } - this.focus(); + + // Only focus items with submenus on hover (to show they're interactive) + // Regular items should not show focus styling on hover, only on keyboard navigation + if (this.hasSubmenu) { + this.focus(); + } this.openOverlay(); } @@ -782,16 +789,6 @@ export class MenuItem extends LikeAnchor( this.handleSubmenuTriggerClick, options ); - this.addEventListener( - 'pointerenter', - this.handlePointerenter, - options - ); - this.addEventListener( - 'pointerleave', - this.handlePointerleave, - options - ); this.addEventListener( 'sp-opened', this.handleSubmenuOpen, From d9a1f4894e5fd4296e7289dac5bc85607a390c5e Mon Sep 17 00:00:00 2001 From: Shipra Gupta Date: Wed, 29 Oct 2025 16:43:07 -0700 Subject: [PATCH 5/5] chore: add changeset for touch device submenu fixes --- .changeset/chubby-birds-leave.md | 5 +++++ package.json | 2 +- yarn.lock | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .changeset/chubby-birds-leave.md diff --git a/.changeset/chubby-birds-leave.md b/.changeset/chubby-birds-leave.md new file mode 100644 index 0000000000..4fd0245bda --- /dev/null +++ b/.changeset/chubby-birds-leave.md @@ -0,0 +1,5 @@ +--- +'@spectrum-web-components/menu': patch +--- + +**Fixed**: Added touch device support for submenu component [#9999](https://github.com/adobe/spectrum-web-components/pull/5818) diff --git a/package.json b/package.json index f8d0da0d86..f3351ffb0a 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ }, "devDependencies": { "@changesets/changelog-github": "0.5.1", - "@changesets/cli": "2.29.7", + "@changesets/cli": "^2.29.7", "@commitlint/cli": "19.8.1", "@commitlint/config-conventional": "19.8.1", "@custom-elements-manifest/analyzer": "0.10.6", diff --git a/yarn.lock b/yarn.lock index 646ccceb50..22387fe51f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -175,7 +175,7 @@ __metadata: resolution: "@adobe/spectrum-web-components@workspace:." dependencies: "@changesets/changelog-github": "npm:0.5.1" - "@changesets/cli": "npm:2.29.7" + "@changesets/cli": "npm:^2.29.7" "@commitlint/cli": "npm:19.8.1" "@commitlint/config-conventional": "npm:19.8.1" "@custom-elements-manifest/analyzer": "npm:0.10.6" @@ -1581,7 +1581,7 @@ __metadata: languageName: node linkType: hard -"@changesets/cli@npm:2.29.7": +"@changesets/cli@npm:^2.29.7": version: 2.29.7 resolution: "@changesets/cli@npm:2.29.7" dependencies: