From 2caf0261e105aebb042424c20ff46e58f72d3b71 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Tue, 16 Sep 2025 16:46:55 +0530 Subject: [PATCH 01/10] fix(menu): added check to find focused element within root context --- packages/menu/src/MenuItem.ts | 73 ++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/menu/src/MenuItem.ts b/packages/menu/src/MenuItem.ts index 05f899b1f1a..c89b604cb40 100644 --- a/packages/menu/src/MenuItem.ts +++ b/packages/menu/src/MenuItem.ts @@ -479,13 +479,84 @@ export class MenuItem extends LikeAnchor( this.id = `sp-menu-item-${randomID()}`; } } + handleMouseover(event: MouseEvent): void { const target = event.target as HTMLElement; if (target === this) { - this.focus(); + // Get the currently focused element within the component's root context + const rootNode = this.getRootNode() as Document | ShadowRoot; + const activeElement = rootNode.activeElement as HTMLElement; + + // 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 = + /^(SP-SEARCH|SP-TEXTFIELD|SP-NUMBER-FIELD|SP-COMBOBOX|SP-COLOR-FIELD)$/; + if (inputComponentPattern.test(element.tagName)) { + return true; + } + + return false; + } /** * forward key info from keydown event to parent menu */ From c8e94d5f5b0a19179b01cb6a9bb9dec7f33d13d7 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Tue, 16 Sep 2025 16:47:09 +0530 Subject: [PATCH 02/10] fix(menu): added story --- packages/menu/stories/menu.stories.ts | 91 +++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/packages/menu/stories/menu.stories.ts b/packages/menu/stories/menu.stories.ts index bebe33f6148..ebfd2ddb87d 100644 --- a/packages/menu/stories/menu.stories.ts +++ b/packages/menu/stories/menu.stories.ts @@ -28,6 +28,11 @@ import '@spectrum-web-components/icons-workflow/icons/sp-icon-export.js'; import '@spectrum-web-components/icons-workflow/icons/sp-icon-folder-open.js'; import '@spectrum-web-components/icons-workflow/icons/sp-icon-share.js'; import '@spectrum-web-components/icons-workflow/icons/sp-icon-show-menu.js'; +import '@spectrum-web-components/search/sp-search.js'; +import '@spectrum-web-components/textfield/sp-textfield.js'; +import '@spectrum-web-components/number-field/sp-number-field.js'; +import '@spectrum-web-components/combobox/sp-combobox.js'; +import '@spectrum-web-components/color-field/sp-color-field.js'; export default { component: 'sp-menu', @@ -484,3 +489,89 @@ export const dynamicRemoval = (): TemplateResult => { `; }; + +export const InputsWithMenu = (): TemplateResult => { + return html` +
+

Input Focus Demo

+

+ Try typing in any input field below, then hover over the menu + items. The input should maintain focus and not be interrupted. + This demonstrates the fix for focus stealing from all supported input types. +

+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + + + Search Results + Recent Searches + Saved Searches + Advanced Search + Search Settings + Clear History + + +
+ `; +}; From 05d2b472f3586878c82cb6dee96704a729c2e999 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Tue, 16 Sep 2025 16:47:19 +0530 Subject: [PATCH 03/10] fix(menu): added test --- packages/menu/test/menu.test.ts | 144 ++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/packages/menu/test/menu.test.ts b/packages/menu/test/menu.test.ts index 050fc61cd5a..d037d77b045 100644 --- a/packages/menu/test/menu.test.ts +++ b/packages/menu/test/menu.test.ts @@ -23,6 +23,12 @@ import '@spectrum-web-components/menu/sp-menu-divider.js'; import '@spectrum-web-components/menu/sp-menu-group.js'; import '@spectrum-web-components/menu/sp-menu-item.js'; import '@spectrum-web-components/menu/sp-menu.js'; +import '@spectrum-web-components/search/sp-search.js'; +import '@spectrum-web-components/textfield/sp-textfield.js'; +import '@spectrum-web-components/number-field/sp-number-field.js'; +import '@spectrum-web-components/combobox/sp-combobox.js'; +import '@spectrum-web-components/color-field/sp-color-field.js'; +import '@spectrum-web-components/popover/sp-popover.js'; import { isFirefox, isWebKit } from '@spectrum-web-components/shared'; import { sendKeys } from '@web/test-runner-commands'; import { spy } from 'sinon'; @@ -838,4 +844,142 @@ describe('Menu', () => { // Test that the component can be disconnected without errors el.remove(); }); + + it('does not steal focus from input elements on mouseover', async () => { + const el = await fixture(html` +
+ + + + + + + + + + + Menu Item 1 + + + Menu Item 2 + + + Menu Item 3 + + + +
+ `); + + await elementUpdated(el); + + const searchInput = el.querySelector('#test-search') as HTMLElement; + const textfieldInput = el.querySelector( + '#test-textfield' + ) as HTMLElement; + const numberInput = el.querySelector('#test-number') as HTMLElement; + const comboboxInput = el.querySelector('#test-combobox') as HTMLElement; + const colorInput = el.querySelector('#test-color') as HTMLElement; + const nativeInput = el.querySelector( + '#test-native' + ) as HTMLInputElement; + + const menuItem1 = el.querySelector('#menu-item-1') as MenuItem; + const menuItem2 = el.querySelector('#menu-item-2') as MenuItem; + const menuItem3 = el.querySelector('#menu-item-3') as MenuItem; + + // Test with sp-search + searchInput.focus(); + await elementUpdated(el); + expect(document.activeElement).to.equal(searchInput); + + await sendMouseTo(menuItem1); + await elementUpdated(el); + expect(document.activeElement).to.equal( + searchInput, + 'sp-search should retain focus' + ); + + await sendMouseTo(menuItem2); + await elementUpdated(el); + expect(document.activeElement).to.equal( + searchInput, + 'sp-search should retain focus' + ); + + // Test with sp-textfield + textfieldInput.focus(); + await elementUpdated(el); + expect(document.activeElement).to.equal(textfieldInput); + + await sendMouseTo(menuItem1); + await elementUpdated(el); + expect(document.activeElement).to.equal( + textfieldInput, + 'sp-textfield should retain focus' + ); + + // Test with sp-number-field + numberInput.focus(); + await elementUpdated(el); + expect(document.activeElement).to.equal(numberInput); + + await sendMouseTo(menuItem2); + await elementUpdated(el); + expect(document.activeElement).to.equal( + numberInput, + 'sp-number-field should retain focus' + ); + + // Test with sp-combobox + comboboxInput.focus(); + await elementUpdated(el); + expect(document.activeElement).to.equal(comboboxInput); + + await sendMouseTo(menuItem3); + await elementUpdated(el); + expect(document.activeElement).to.equal( + comboboxInput, + 'sp-combobox should retain focus' + ); + + // Test with sp-color-field + colorInput.focus(); + await elementUpdated(el); + expect(document.activeElement).to.equal(colorInput); + + await sendMouseTo(menuItem1); + await elementUpdated(el); + expect(document.activeElement).to.equal( + colorInput, + 'sp-color-field should retain focus' + ); + + // Test with native input + nativeInput.focus(); + await elementUpdated(el); + expect(document.activeElement).to.equal(nativeInput); + + await sendMouseTo(menuItem2); + await elementUpdated(el); + expect(document.activeElement).to.equal( + nativeInput, + 'native input should retain focus' + ); + }); }); From b038e1b608d120a925162871b9670bac7f3fc1c4 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Tue, 16 Sep 2025 16:49:23 +0530 Subject: [PATCH 04/10] chore(menu): added changeset --- .changeset/cruel-eels-open.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cruel-eels-open.md diff --git a/.changeset/cruel-eels-open.md b/.changeset/cruel-eels-open.md new file mode 100644 index 00000000000..1a262f4e355 --- /dev/null +++ b/.changeset/cruel-eels-open.md @@ -0,0 +1,5 @@ +--- +'@spectrum-web-components/menu': minor +--- + +**Fixed** MenuItem focus stealing from input elements on mouseover by enhanceing MenuItem's `handleMouseover` method to detect when an input element currently has focus and prevent stealing focus in those cases. From 7893ce01aacfaa278679b719bea8f76f1cfda960 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Thu, 18 Sep 2025 13:47:54 +0530 Subject: [PATCH 05/10] fix(menu): added global const for component input pattern --- packages/menu/src/MenuItem.ts | 4 ++-- tools/base/src/constants.ts | 29 +++++++++++++++++++++++++++++ tools/base/src/index.ts | 1 + 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 tools/base/src/constants.ts diff --git a/packages/menu/src/MenuItem.ts b/packages/menu/src/MenuItem.ts index c89b604cb40..f6f92cfe822 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, @@ -549,8 +550,7 @@ export class MenuItem extends LikeAnchor( // Check for components that typically contain input elements // This covers components like sp-search, sp-textfield, sp-number-field, etc. - const inputComponentPattern = - /^(SP-SEARCH|SP-TEXTFIELD|SP-NUMBER-FIELD|SP-COMBOBOX|SP-COLOR-FIELD)$/; + const inputComponentPattern = INPUT_COMPONENT_PATTERN; if (inputComponentPattern.test(element.tagName)) { return true; } diff --git a/tools/base/src/constants.ts b/tools/base/src/constants.ts new file mode 100644 index 00000000000..7b7886970ee --- /dev/null +++ b/tools/base/src/constants.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Regular expression pattern to match Spectrum Web Components input elements. + * Used to identify components that should maintain focus during menu interactions. + */ +export const INPUT_COMPONENT_PATTERN = + /^(SP-SEARCH|SP-TEXTFIELD|SP-NUMBER-FIELD|SP-COMBOBOX|SP-COLOR-FIELD)$/; + +/** + * Array of input component tag names for easier iteration and maintenance. + */ +export const INPUT_COMPONENT_TAGS = [ + 'SP-SEARCH', + 'SP-TEXTFIELD', + 'SP-NUMBER-FIELD', + 'SP-COMBOBOX', + 'SP-COLOR-FIELD', +] as const; diff --git a/tools/base/src/index.ts b/tools/base/src/index.ts index 7136a5691af..a5ea4641a95 100644 --- a/tools/base/src/index.ts +++ b/tools/base/src/index.ts @@ -12,4 +12,5 @@ export * from './Base.js'; export * from './sizedMixin.js'; +export * from './constants.js'; export * from 'lit'; From fc347e3649f51cc709beaa21d0796f10bda04cd7 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Mon, 22 Sep 2025 18:07:00 +0530 Subject: [PATCH 06/10] fix(menu): remove delimiter from the regexp constructor --- tools/base/src/constants.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tools/base/src/constants.ts b/tools/base/src/constants.ts index 7b7886970ee..3bd418db887 100644 --- a/tools/base/src/constants.ts +++ b/tools/base/src/constants.ts @@ -10,13 +10,6 @@ * governing permissions and limitations under the License. */ -/** - * Regular expression pattern to match Spectrum Web Components input elements. - * Used to identify components that should maintain focus during menu interactions. - */ -export const INPUT_COMPONENT_PATTERN = - /^(SP-SEARCH|SP-TEXTFIELD|SP-NUMBER-FIELD|SP-COMBOBOX|SP-COLOR-FIELD)$/; - /** * Array of input component tag names for easier iteration and maintenance. */ @@ -27,3 +20,11 @@ export const INPUT_COMPONENT_TAGS = [ 'SP-COMBOBOX', 'SP-COLOR-FIELD', ] as const; + +/** + * Regular expression pattern to match Spectrum Web Components input elements. + * Used to identify components that should maintain focus during menu interactions. + */ +export const INPUT_COMPONENT_PATTERN = new RegExp( + `^(${INPUT_COMPONENT_TAGS.join('|')})$` +); From f1dddc887c33803defc2b5e0b91143d86e0b5aa0 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Wed, 24 Sep 2025 16:34:23 +0530 Subject: [PATCH 07/10] chore: skipped prod and vrt tests on the new story --- packages/menu/stories/menu.stories.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/menu/stories/menu.stories.ts b/packages/menu/stories/menu.stories.ts index ebfd2ddb87d..992d1a1d1ad 100644 --- a/packages/menu/stories/menu.stories.ts +++ b/packages/menu/stories/menu.stories.ts @@ -497,10 +497,13 @@ export const InputsWithMenu = (): TemplateResult => {

Try typing in any input field below, then hover over the menu items. The input should maintain focus and not be interrupted. - This demonstrates the fix for focus stealing from all supported input types. + This demonstrates the fix for focus stealing from all supported + input types.

-
+
@@ -575,3 +578,16 @@ export const InputsWithMenu = (): TemplateResult => {
`; }; + +InputsWithMenu.parameters = { + tags: ['!dev'], +}; + +InputsWithMenu.swc_vrt = { + skip: true, +}; + +InputsWithMenu.parameters = { + // Disables Chromatic's snapshotting on a global level + chromatic: { disableSnapshot: true }, +}; From b768abc7035aa268736906c51f3f16d78146c01e Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Thu, 9 Oct 2025 13:29:51 +0530 Subject: [PATCH 08/10] chore: fix tests helpers --- packages/menu/test/menu.test.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/menu/test/menu.test.ts b/packages/menu/test/menu.test.ts index aeacd363c89..62b523c7822 100644 --- a/packages/menu/test/menu.test.ts +++ b/packages/menu/test/menu.test.ts @@ -904,18 +904,18 @@ describe('Menu', () => { await elementUpdated(el); expect(document.activeElement).to.equal(searchInput); - await sendMouseTo(menuItem1); + await mouseMoveOver(menuItem1); await elementUpdated(el); expect(document.activeElement).to.equal( searchInput, - 'sp-search should retain focus' + 'sp-search should retain focus on mouseover' ); - await sendMouseTo(menuItem2); + await mouseMoveOver(menuItem2); await elementUpdated(el); expect(document.activeElement).to.equal( searchInput, - 'sp-search should retain focus' + 'sp-search should retain focus on mouseover' ); // Test with sp-textfield @@ -923,11 +923,11 @@ describe('Menu', () => { await elementUpdated(el); expect(document.activeElement).to.equal(textfieldInput); - await sendMouseTo(menuItem1); + await mouseMoveOver(menuItem1); await elementUpdated(el); expect(document.activeElement).to.equal( textfieldInput, - 'sp-textfield should retain focus' + 'sp-textfield should retain focus on mouseover' ); // Test with sp-number-field @@ -935,11 +935,11 @@ describe('Menu', () => { await elementUpdated(el); expect(document.activeElement).to.equal(numberInput); - await sendMouseTo(menuItem2); + await mouseMoveOver(menuItem2); await elementUpdated(el); expect(document.activeElement).to.equal( numberInput, - 'sp-number-field should retain focus' + 'sp-number-field should retain focus on mouseover' ); // Test with sp-combobox @@ -947,11 +947,11 @@ describe('Menu', () => { await elementUpdated(el); expect(document.activeElement).to.equal(comboboxInput); - await sendMouseTo(menuItem3); + await mouseMoveOver(menuItem3); await elementUpdated(el); expect(document.activeElement).to.equal( comboboxInput, - 'sp-combobox should retain focus' + 'sp-combobox should retain focus on mouseover' ); // Test with sp-color-field @@ -959,11 +959,11 @@ describe('Menu', () => { await elementUpdated(el); expect(document.activeElement).to.equal(colorInput); - await sendMouseTo(menuItem1); + await mouseMoveOver(menuItem1); await elementUpdated(el); expect(document.activeElement).to.equal( colorInput, - 'sp-color-field should retain focus' + 'sp-color-field should retain focus on mouseover' ); // Test with native input @@ -971,11 +971,11 @@ describe('Menu', () => { await elementUpdated(el); expect(document.activeElement).to.equal(nativeInput); - await sendMouseTo(menuItem2); + await mouseMoveOver(menuItem2); await elementUpdated(el); expect(document.activeElement).to.equal( nativeInput, - 'native input should retain focus' + 'native input should retain focus on mouseover' ); }); }); From 1e3d057557b6c87bc39a0eed9408f37537b4e765 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Thu, 9 Oct 2025 19:37:10 +0530 Subject: [PATCH 09/10] fix: check for cross root boundary --- packages/menu/src/MenuItem.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/menu/src/MenuItem.ts b/packages/menu/src/MenuItem.ts index f6f92cfe822..c006e3bcf9e 100644 --- a/packages/menu/src/MenuItem.ts +++ b/packages/menu/src/MenuItem.ts @@ -481,12 +481,31 @@ export class MenuItem extends LikeAnchor( } } + private getActiveElementSafely(): HTMLElement | null { + // Use a more robust approach to find active element across shadow boundaries + 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) { - // Get the currently focused element within the component's root context - const rootNode = this.getRootNode() as Document | ShadowRoot; - const activeElement = rootNode.activeElement as HTMLElement; + // 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. From 74f9dfcea1f641559ab866913d0b4a0c5db46e87 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Thu, 9 Oct 2025 19:37:53 +0530 Subject: [PATCH 10/10] fix: code comments --- packages/menu/src/MenuItem.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/menu/src/MenuItem.ts b/packages/menu/src/MenuItem.ts index c006e3bcf9e..75de1e0de3d 100644 --- a/packages/menu/src/MenuItem.ts +++ b/packages/menu/src/MenuItem.ts @@ -482,7 +482,6 @@ export class MenuItem extends LikeAnchor( } private getActiveElementSafely(): HTMLElement | null { - // Use a more robust approach to find active element across shadow boundaries let root = this.getRootNode() as Document | ShadowRoot; let activeElement = root.activeElement as HTMLElement;