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. diff --git a/packages/menu/src/MenuItem.ts b/packages/menu/src/MenuItem.ts index 05f899b1f1a..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, @@ -479,13 +480,83 @@ 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 = INPUT_COMPONENT_PATTERN; + if (inputComponentPattern.test(element.tagName)) { + return true; + } + + return false; + } /** * forward key info from keydown event to parent menu */ diff --git a/packages/menu/stories/menu.stories.ts b/packages/menu/stories/menu.stories.ts index bebe33f6148..992d1a1d1ad 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,105 @@ 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 + + +
+ `; +}; + +InputsWithMenu.parameters = { + tags: ['!dev'], +}; + +InputsWithMenu.swc_vrt = { + skip: true, +}; + +InputsWithMenu.parameters = { + // Disables Chromatic's snapshotting on a global level + chromatic: { disableSnapshot: true }, +}; 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' + ); + }); }); diff --git a/tools/base/src/constants.ts b/tools/base/src/constants.ts new file mode 100644 index 00000000000..3bd418db887 --- /dev/null +++ b/tools/base/src/constants.ts @@ -0,0 +1,30 @@ +/** + * 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. + */ + +/** + * 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; + +/** + * 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('|')})$` +); 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';