[select] Decouple Select styles from NativeSelect

SelectInput reused the styled select and icon from NativeSelectInput,
so NativeSelect carried the open icon rotation and the iconOpen class
even though a native select never reports being open, and
MuiNativeSelect theme variants also styled the Select display and icon.

Give SelectInput its own styles, drop the open handling from
NativeSelectInput and document that NativeSelect never applies iconOpen.
This commit is contained in:
dualfroz committed 2026-10-17 03:50:37 +02:00
1 parent ab962ad252
commit 91f1112675
6 files changed
+164 -27

No files matched your search

@@ -70,7 +70,7 @@
{ {
"key": "iconOpen", "key": "iconOpen",
"className": "MuiNativeSelect-iconOpen", "className": "MuiNativeSelect-iconOpen",
"description": "Styles applied to the icon component if the popup is open.", "description": "This class is never applied, a native select doesn't expose whether its popup is open.",
"isGlobal": false "isGlobal": false
}, },
{ {
@@ -48,9 +48,7 @@
"conditions": "<code>variant=\"filled\"</code>" "conditions": "<code>variant=\"filled\"</code>"
}, },
"iconOpen": { "iconOpen": {
"description": "Styles applied to {{nodeName}} if {{conditions}}.", "description": "This class is never applied, a native select doesn&#39;t expose whether its popup is open."
"nodeName": "the icon component",
"conditions": "the popup is open"
}, },
"iconOutlined": { "iconOutlined": {
"description": "Styles applied to {{nodeName}} if {{conditions}}.", "description": "Styles applied to {{nodeName}} if {{conditions}}.",
@@ -12,17 +12,17 @@ import rootShouldForwardProp from '../styles/rootShouldForwardProp';
import inputAdornmentClasses from '../InputAdornment/inputAdornmentClasses'; import inputAdornmentClasses from '../InputAdornment/inputAdornmentClasses';
const useUtilityClasses = (ownerState) => { const useUtilityClasses = (ownerState) => {
const { classes, variant, disabled, multiple, open, error } = ownerState; const { classes, variant, disabled, multiple, error } = ownerState;
const slots = { const slots = {
select: ['select', variant, disabled && 'disabled', multiple && 'multiple', error && 'error'], select: ['select', variant, disabled && 'disabled', multiple && 'multiple', error && 'error'],
icon: ['icon', `icon${capitalize(variant)}`, open && 'iconOpen', disabled && 'disabled'], icon: ['icon', `icon${capitalize(variant)}`, disabled && 'disabled'],
}; };
return composeClasses(slots, getNativeSelectUtilityClasses, classes); return composeClasses(slots, getNativeSelectUtilityClasses, classes);
}; };
export const StyledSelectSelect = styled('select', { const StyledSelectSelect = styled('select', {
name: 'MuiNativeSelect', name: 'MuiNativeSelect',
})(({ theme }) => ({ })(({ theme }) => ({
// Reset // Reset
@@ -130,7 +130,7 @@ const NativeSelectSelect = styled(StyledSelectSelect, {
}, },
})({}); })({});
export const StyledSelectIcon = styled('svg', { const StyledSelectIcon = styled('svg', {
name: 'MuiNativeSelect', name: 'MuiNativeSelect',
})(({ theme }) => ({ })(({ theme }) => ({
// We use a position absolute over a flexbox in order to forward the pointer events // We use a position absolute over a flexbox in order to forward the pointer events
@@ -146,12 +146,6 @@ export const StyledSelectIcon = styled('svg', {
color: (theme.vars || theme).palette.action.disabled, color: (theme.vars || theme).palette.action.disabled,
}, },
variants: [ variants: [
{
props: ({ ownerState }) => ownerState.open,
style: {
transform: 'rotate(180deg)',
},
},
{ {
props: { props: {
variant: 'filled', variant: 'filled',
@@ -176,11 +170,7 @@ const NativeSelectIcon = styled(StyledSelectIcon, {
slot: 'Icon', slot: 'Icon',
overridesResolver: (props, styles) => { overridesResolver: (props, styles) => {
const { ownerState } = props; const { ownerState } = props;
return [ return [styles.icon, ownerState.variant && styles[`icon${capitalize(ownerState.variant)}`]];
styles.icon,
ownerState.variant && styles[`icon${capitalize(ownerState.variant)}`],
ownerState.open && styles.iconOpen,
];
}, },
})({}); })({});
@@ -18,7 +18,7 @@ export interface NativeSelectClasses {
disabled: string; disabled: string;
/** Styles applied to the icon component. */ /** Styles applied to the icon component. */
icon: string; icon: string;
/** Styles applied to the icon component if the popup is open. */ /** This class is never applied, a native select doesn't expose whether its popup is open. */
iconOpen: string; iconOpen: string;
/** Styles applied to the icon component if `variant="filled"`. */ /** Styles applied to the icon component if `variant="filled"`. */
iconFilled: string; iconFilled: string;
@@ -2774,6 +2774,41 @@ describe('<Select />', () => {
}); });
}); });
it('should not apply MuiNativeSelect theme variants', () => {
const theme = createTheme({
components: {
MuiNativeSelect: {
variants: [
{
props: {
variant: 'outlined',
},
style: {
textTransform: 'uppercase',
},
},
],
},
},
});
const { container } = render(
<ThemeProvider theme={theme}>
<Select variant="outlined" value="first">
<MenuItem value="first" />
<MenuItem value="second" />
</Select>
</ThemeProvider>,
);
expect(container.querySelector(`.${classes.select}`)).not.toHaveComputedStyle({
textTransform: 'uppercase',
});
expect(container.querySelector(`.${classes.icon}`)).not.toHaveComputedStyle({
textTransform: 'uppercase',
});
});
describe('prop: input', () => { describe('prop: input', () => {
it('merges `ref` of `Select` and `input`', () => { it('merges `ref` of `Select` and `input`', () => {
const Input = React.forwardRef(function Input(props, ref) { const Input = React.forwardRef(function Input(props, ref) {
+121 -7
View File
@@ -9,8 +9,9 @@ import refType from '@mui/utils/refType';
import useTimeout from '@mui/utils/useTimeout'; import useTimeout from '@mui/utils/useTimeout';
import ownerDocument from '../utils/ownerDocument'; import ownerDocument from '../utils/ownerDocument';
import Menu from '../Menu/Menu'; import Menu from '../Menu/Menu';
import { StyledSelectSelect, StyledSelectIcon } from '../NativeSelect/NativeSelectInput';
import { isFilled } from '../InputBase/utils'; import { isFilled } from '../InputBase/utils';
import inputBaseClasses from '../InputBase/inputBaseClasses';
import inputAdornmentClasses from '../InputAdornment/inputAdornmentClasses';
import { styled } from '../zero-styled'; import { styled } from '../zero-styled';
import slotShouldForwardProp from '../styles/slotShouldForwardProp'; import slotShouldForwardProp from '../styles/slotShouldForwardProp';
import useEnhancedEffect from '../utils/useEnhancedEffect'; import useEnhancedEffect from '../utils/useEnhancedEffect';
@@ -72,7 +73,7 @@ function isMouseEventInsideElement(event, element) {
); );
} }
const SelectSelect = styled(StyledSelectSelect, { const SelectSelect = styled('div', {
name: 'MuiSelect', name: 'MuiSelect',
slot: 'Select', slot: 'Select',
overridesResolver: (props, styles) => { overridesResolver: (props, styles) => {
@@ -85,7 +86,29 @@ const SelectSelect = styled(StyledSelectSelect, {
{ [`&.${selectClasses.multiple}`]: styles.multiple }, { [`&.${selectClasses.multiple}`]: styles.multiple },
]; ];
}, },
})({ })(({ theme }) => ({
// When interacting quickly, the text can end up selected.
// Native select can't be selected either.
userSelect: 'none',
// Reset
borderRadius: 0,
cursor: 'pointer',
'&:focus': {
// Reset Chrome style
borderRadius: 0,
},
[`&.${selectClasses.disabled}`]: {
cursor: 'default',
},
[`& ~ .${inputAdornmentClasses.root}`]: {
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
right: 'calc(var(--_caret, 24px) + (var(--_endAdornment, 28px) - 1.5rem)/2)', // 1.5rem is the default icon size
},
[`.${inputBaseClasses.root}:has(> &)`]: {
'--_endAdornment': '0px',
},
// Win specificity over the input base // Win specificity over the input base
[`&.${selectClasses.select}`]: { [`&.${selectClasses.select}`]: {
height: 'auto', // Resets for multiple select with chips height: 'auto', // Resets for multiple select with chips
@@ -94,16 +117,108 @@ const SelectSelect = styled(StyledSelectSelect, {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
}, },
}); variants: [
{
props: ({ ownerState }) =>
ownerState.variant !== 'filled' && ownerState.variant !== 'outlined',
style: {
[`.${inputBaseClasses.root}:has(> &)`]: {
'--_caret': '24px',
},
[`.${inputBaseClasses.root}:has(> & ~ .${inputAdornmentClasses.root})`]: {
'--_endAdornment': '28px',
},
// Bump specificity to allow extending custom inputs
'&&&': {
paddingRight: 'calc(var(--_caret, 24px) + var(--_endAdornment, 0px))',
minWidth: 16, // So it doesn't collapse.
},
},
},
{
props: {
variant: 'filled',
},
style: {
[`.${inputBaseClasses.root}:has(> &)`]: {
'--_caret': '32px',
},
[`.${inputBaseClasses.root}:has(> & ~ .${inputAdornmentClasses.root})`]: {
'--_endAdornment': '28px',
},
'&&&': {
paddingRight: 'calc(var(--_caret, 32px) + var(--_endAdornment, 0px))',
},
},
},
{
props: {
variant: 'outlined',
},
style: {
[`.${inputBaseClasses.root}:has(> &)`]: {
'--_caret': '32px',
},
[`.${inputBaseClasses.root}:has(> & ~ .${inputAdornmentClasses.root})`]: {
'--_endAdornment': '28px',
},
borderRadius: (theme.vars || theme).shape.borderRadius,
'&:focus': {
borderRadius: (theme.vars || theme).shape.borderRadius, // Reset the reset for Chrome style
},
'&&&': {
paddingRight: 'calc(var(--_caret, 32px) + var(--_endAdornment, 0px))',
},
},
},
],
}));
const SelectIcon = styled(StyledSelectIcon, { const SelectIcon = styled('svg', {
name: 'MuiSelect', name: 'MuiSelect',
slot: 'Icon', slot: 'Icon',
overridesResolver: (props, styles) => { overridesResolver: (props, styles) => {
const { ownerState } = props; const { ownerState } = props;
return [styles.icon, ownerState.open && styles.iconOpen]; return [styles.icon, ownerState.open && styles.iconOpen];
}, },
})({}); })(({ theme }) => ({
// We use a position absolute over a flexbox in order to forward the pointer events
// to the input and to support wrapping tags..
position: 'absolute',
right: 0,
// Center vertically, height is 1em
top: 'calc(50% - .5em)',
// Don't block pointer events on the select under the icon.
pointerEvents: 'none',
color: (theme.vars || theme).palette.action.active,
[`&.${selectClasses.disabled}`]: {
color: (theme.vars || theme).palette.action.disabled,
},
variants: [
{
props: ({ ownerState }) => ownerState.open,
style: {
transform: 'rotate(180deg)',
},
},
{
props: {
variant: 'filled',
},
style: {
right: 7,
},
},
{
props: {
variant: 'outlined',
},
style: {
right: 7,
},
},
],
}));
const SelectNativeInput = styled('input', { const SelectNativeInput = styled('input', {
shouldForwardProp: (prop) => slotShouldForwardProp(prop) && prop !== 'classes', shouldForwardProp: (prop) => slotShouldForwardProp(prop) && prop !== 'classes',
@@ -838,7 +953,6 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
return ( return (
<React.Fragment> <React.Fragment>
<SelectSelect <SelectSelect
as="div"
ref={handleDisplayRef} ref={handleDisplayRef}
tabIndex={tabIndex} tabIndex={tabIndex}
role="combobox" role="combobox"