diff --git a/packages/mui-material/src/MenuItem/MenuItem.js b/packages/mui-material/src/MenuItem/MenuItem.js
index 3f6cf11e6fa484..731a407539aa32 100644
--- a/packages/mui-material/src/MenuItem/MenuItem.js
+++ b/packages/mui-material/src/MenuItem/MenuItem.js
@@ -15,6 +15,25 @@ import { dividerClasses } from '../Divider';
import { listItemIconClasses } from '../ListItemIcon';
import { listItemTextClasses } from '../ListItemText';
import menuItemClasses, { getMenuItemUtilityClass } from './menuItemClasses';
+import { useSelectFocusSource } from '../Select';
+
+/**
+ * If autoFocus is an object, it will attempt to call `element.focus()` with the options argument.
+ * If the browser doesn't support the options argument, it will fall back to a simple `element.focus()` call.
+ */
+function focusWithVisible(element, focusSource) {
+ if (focusSource == null) {
+ element.focus();
+ return;
+ }
+
+ try {
+ element.focus({ focusVisible: focusSource === 'keyboard' });
+ } catch (error) {
+ // If the browser doesn't support the focus options argument, fall back to a simple focus call.
+ element.focus();
+ }
+}
export const overridesResolver = (props, styles) => {
const { ownerState } = props;
@@ -176,6 +195,7 @@ const MenuItem = React.forwardRef(function MenuItem(inProps, ref) {
...other
} = props;
+ const focusSource = useSelectFocusSource();
const context = React.useContext(ListContext);
const childContext = React.useMemo(
() => ({
@@ -189,13 +209,14 @@ const MenuItem = React.forwardRef(function MenuItem(inProps, ref) {
useEnhancedEffect(() => {
if (autoFocus) {
if (menuItemRef.current) {
- menuItemRef.current.focus();
+ focusWithVisible(menuItemRef.current, focusSource);
} else if (process.env.NODE_ENV !== 'production') {
console.error(
'MUI: Unable to set focus to a MenuItem whose component has not been rendered.',
);
}
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoFocus]);
const ownerState = {
diff --git a/packages/mui-material/src/Select/SelectInput.js b/packages/mui-material/src/Select/SelectInput.js
index f3265fba214764..68c8ec6ba99f90 100644
--- a/packages/mui-material/src/Select/SelectInput.js
+++ b/packages/mui-material/src/Select/SelectInput.js
@@ -16,6 +16,8 @@ import slotShouldForwardProp from '../styles/slotShouldForwardProp';
import useForkRef from '../utils/useForkRef';
import useControlled from '../utils/useControlled';
import selectClasses, { getSelectUtilityClasses } from './selectClasses';
+import { areEqualValues, isEmpty, getOpenInteractionType } from './utils';
+import { SelectFocusSourceProvider } from './utils/SelectFocusSourceContext';
const SelectSelect = styled(StyledSelectSelect, {
name: 'MuiSelect',
@@ -68,19 +70,6 @@ const SelectNativeInput = styled('input', {
boxSizing: 'border-box',
});
-function areEqualValues(a, b) {
- if (typeof b === 'object' && b !== null) {
- return a === b;
- }
-
- // The value could be a number, the DOM will stringify it anyway.
- return String(a) === String(b);
-}
-
-function isEmpty(display) {
- return display == null || (typeof display === 'string' && !display.trim());
-}
-
const useUtilityClasses = (ownerState) => {
const { classes, variant, disabled, multiple, open, error } = ownerState;
@@ -153,7 +142,7 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
const [displayNode, setDisplayNode] = React.useState(null);
const { current: isOpenControlled } = React.useRef(openProp != null);
const [menuMinWidthState, setMenuMinWidthState] = React.useState();
-
+ const [openInteractionType, setOpenInteractionType] = React.useState(null);
const handleRef = useForkRef(ref, inputRefProp);
const handleDisplayRef = React.useCallback((node) => {
@@ -238,11 +227,17 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
const update = (openParam, event) => {
if (openParam) {
+ setOpenInteractionType(getOpenInteractionType(event));
+
if (onOpen) {
onOpen(event);
}
- } else if (onClose) {
- onClose(event);
+ } else {
+ setOpenInteractionType(null);
+
+ if (onClose) {
+ onClose(event);
+ }
}
if (!isOpenControlled) {
@@ -577,41 +572,43 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
ownerState={ownerState}
/>
-
+
);
});
diff --git a/packages/mui-material/src/Select/index.d.ts b/packages/mui-material/src/Select/index.d.ts
index cda0a7d7864f95..017c33214ac9e0 100644
--- a/packages/mui-material/src/Select/index.d.ts
+++ b/packages/mui-material/src/Select/index.d.ts
@@ -1,5 +1,6 @@
export { default } from './Select';
export * from './Select';
+export * from './utils';
export { default as selectClasses } from './selectClasses';
export * from './selectClasses';
diff --git a/packages/mui-material/src/Select/index.js b/packages/mui-material/src/Select/index.js
index 59eafa5d813fe0..f4c81c0fa31bae 100644
--- a/packages/mui-material/src/Select/index.js
+++ b/packages/mui-material/src/Select/index.js
@@ -1,4 +1,5 @@
export { default } from './Select';
+export * from './utils';
export { default as selectClasses } from './selectClasses';
export * from './selectClasses';
diff --git a/packages/mui-material/src/Select/utils/SelectFocusSourceContext.ts b/packages/mui-material/src/Select/utils/SelectFocusSourceContext.ts
new file mode 100644
index 00000000000000..e9097254de9d5e
--- /dev/null
+++ b/packages/mui-material/src/Select/utils/SelectFocusSourceContext.ts
@@ -0,0 +1,18 @@
+'use client';
+import * as React from 'react';
+
+const SelectFocusSourceContext = React.createContext<'keyboard' | 'mouse' | 'touch' | null>(null);
+
+if (process.env.NODE_ENV !== 'production') {
+ SelectFocusSourceContext.displayName = 'SelectFocusSourceContext';
+}
+
+function useSelectFocusSource() {
+ const context = React.useContext(SelectFocusSourceContext);
+
+ return context;
+}
+
+const SelectFocusSourceProvider = SelectFocusSourceContext.Provider;
+
+export { useSelectFocusSource, SelectFocusSourceProvider };
diff --git a/packages/mui-material/src/Select/utils/areEqualValues.ts b/packages/mui-material/src/Select/utils/areEqualValues.ts
new file mode 100644
index 00000000000000..48ea8a940341cb
--- /dev/null
+++ b/packages/mui-material/src/Select/utils/areEqualValues.ts
@@ -0,0 +1,8 @@
+export default function areEqualValues(a: unknown, b: unknown): boolean {
+ if (typeof b === 'object' && b !== null) {
+ return a === b;
+ }
+
+ // The value could be a number, the DOM will stringify it anyway.
+ return String(a) === String(b);
+}
diff --git a/packages/mui-material/src/Select/utils/getOpenInteractionType.ts b/packages/mui-material/src/Select/utils/getOpenInteractionType.ts
new file mode 100644
index 00000000000000..51ba2b38546e8a
--- /dev/null
+++ b/packages/mui-material/src/Select/utils/getOpenInteractionType.ts
@@ -0,0 +1,17 @@
+export default function getOpenInteractionType(
+ event: MouseEvent | KeyboardEvent | TouchEvent | PointerEvent | null,
+): 'keyboard' | 'pointer' | null {
+ if (!event) {
+ return null;
+ }
+
+ if (event.type === 'mousedown' || event.type === 'pointerdown' || event.type === 'touchstart') {
+ return 'pointer';
+ }
+
+ if (event.type === 'keydown' || (event.type === 'click' && event.detail === 0)) {
+ return 'keyboard';
+ }
+
+ return null;
+}
diff --git a/packages/mui-material/src/Select/utils/index.ts b/packages/mui-material/src/Select/utils/index.ts
new file mode 100644
index 00000000000000..8ccfced0c2801e
--- /dev/null
+++ b/packages/mui-material/src/Select/utils/index.ts
@@ -0,0 +1,4 @@
+export { default as getOpenInteractionType } from './getOpenInteractionType';
+export { default as isEmpty } from './isEmpty';
+export { default as areEqualValues } from './areEqualValues';
+export { useSelectFocusSource, SelectFocusSourceProvider } from './SelectFocusSourceContext';
diff --git a/packages/mui-material/src/Select/utils/isEmpty.ts b/packages/mui-material/src/Select/utils/isEmpty.ts
new file mode 100644
index 00000000000000..5aa4d803da74fe
--- /dev/null
+++ b/packages/mui-material/src/Select/utils/isEmpty.ts
@@ -0,0 +1,3 @@
+export default function isEmpty(display: unknown) {
+ return display == null || (typeof display === 'string' && !display.trim());
+}
diff --git a/test/README.md b/test/README.md
index 1e240e61c9b858..d46e869142ab1b 100644
--- a/test/README.md
+++ b/test/README.md
@@ -158,7 +158,7 @@ When running this command you should get under `coverage/index.html` a full cove
### DOM API level
-#### Run the browser test suit
+#### Run the browser test suite
`pnpm test:browser`
diff --git a/test/e2e/fixtures/Select/SelectFocusVisible.tsx b/test/e2e/fixtures/Select/SelectFocusVisible.tsx
new file mode 100644
index 00000000000000..386ad9d81eb3ab
--- /dev/null
+++ b/test/e2e/fixtures/Select/SelectFocusVisible.tsx
@@ -0,0 +1,13 @@
+import * as React from 'react';
+import Select from '@mui/material/Select';
+import MenuItem from '@mui/material/MenuItem';
+
+export default function SelectFocusVisible() {
+ return (
+
+ );
+}
diff --git a/test/e2e/index.test.ts b/test/e2e/index.test.ts
index bb1c355506ff02..ff13f9dccd81eb 100644
--- a/test/e2e/index.test.ts
+++ b/test/e2e/index.test.ts
@@ -264,4 +264,40 @@ describe('e2e', () => {
await errorSelector.waitFor();
});
});
+
+ describe('', () => {
+ it('should not show focus-visible on menu item when opened by mouse', async () => {
+ await renderFixture('Select/SelectFocusVisible');
+
+ const trigger = page.getByRole('combobox');
+ await trigger.click();
+
+ await page.waitForSelector('[role="listbox"]');
+
+ const selectedItem = page.locator('[role="option"][aria-selected="true"]');
+ await expect(selectedItem).toBeFocused();
+ const hasVisible = await selectedItem.evaluate((el) =>
+ el.classList.contains('Mui-focusVisible'),
+ );
+ expect(hasVisible).toEqual(false);
+ });
+
+ it('should show focus-visible on menu item when opened by keyboard', async () => {
+ await renderFixture('Select/SelectFocusVisible');
+
+ await page.keyboard.press('Tab');
+ const trigger = page.getByRole('combobox');
+ await expect(trigger).toBeFocused();
+
+ await page.keyboard.press('Enter');
+ await page.waitForSelector('[role="listbox"]');
+
+ const selectedItem = page.locator('[role="option"][aria-selected="true"]');
+ await expect(selectedItem).toBeFocused();
+ const hasVisible = await selectedItem.evaluate((el) =>
+ el.classList.contains('Mui-focusVisible'),
+ );
+ expect(hasVisible).toEqual(true);
+ });
+ });
});
diff --git a/test/setupVitest.ts b/test/setupVitest.ts
index 7f41ebf7f3c709..ac7a88a122d9a1 100644
--- a/test/setupVitest.ts
+++ b/test/setupVitest.ts
@@ -1,3 +1,30 @@
+import { beforeAll, afterAll } from 'vitest';
import setupVitest from '@mui/internal-test-utils/setupVitest';
setupVitest({ emotion: true });
+
+// In Firefox, calling focus() with arguments (e.g. focusOptions) fails silently,
+// which causes focus-visible related tests to fail as a consequence.
+// This override is only applied in a browser environment running Firefox.
+if (typeof globalThis.navigator !== 'undefined' && !navigator.userAgent.includes('jsdom')) {
+ const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');
+
+ if (isFirefox) {
+ const originalFocus = HTMLElement.prototype.focus;
+
+ beforeAll(() => {
+ Object.defineProperty(HTMLElement.prototype, 'focus', {
+ configurable: true,
+ value: function focusWithoutArguments() {
+ originalFocus.call(this); // always call without arguments
+ },
+ });
+ });
+
+ afterAll(() => {
+ Object.defineProperty(HTMLElement.prototype, 'focus', {
+ value: originalFocus,
+ });
+ });
+ }
+}