From 722d54899ac5d3d2f24d22b7ea902924b457892a Mon Sep 17 00:00:00 2001 From: dualfroz Date: Sat, 17 Oct 2026 03:50:37 +0200 Subject: [PATCH 1/3] [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. --- docs/pages/material-ui/api/native-select.json | 2 +- .../api-docs/native-select/native-select.json | 4 +- .../src/NativeSelect/NativeSelectInput.js | 20 +-- .../src/NativeSelect/nativeSelectClasses.ts | 2 +- .../mui-material/src/Select/Select.test.js | 35 +++++ .../mui-material/src/Select/SelectInput.js | 128 +++++++++++++++++- 6 files changed, 164 insertions(+), 27 deletions(-) diff --git a/docs/pages/material-ui/api/native-select.json b/docs/pages/material-ui/api/native-select.json index 6caa6f0108b414..fa37463e4aae8f 100644 --- a/docs/pages/material-ui/api/native-select.json +++ b/docs/pages/material-ui/api/native-select.json @@ -70,7 +70,7 @@ { "key": "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 }, { diff --git a/docs/translations/api-docs/native-select/native-select.json b/docs/translations/api-docs/native-select/native-select.json index 99344b7d720f13..2d6ff6fa5f2bf4 100644 --- a/docs/translations/api-docs/native-select/native-select.json +++ b/docs/translations/api-docs/native-select/native-select.json @@ -48,9 +48,7 @@ "conditions": "variant=\"filled\"" }, "iconOpen": { - "description": "Styles applied to {{nodeName}} if {{conditions}}.", - "nodeName": "the icon component", - "conditions": "the popup is open" + "description": "This class is never applied, a native select doesn't expose whether its popup is open." }, "iconOutlined": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", diff --git a/packages/mui-material/src/NativeSelect/NativeSelectInput.js b/packages/mui-material/src/NativeSelect/NativeSelectInput.js index c7b420d7c03cff..d08e23517b449e 100644 --- a/packages/mui-material/src/NativeSelect/NativeSelectInput.js +++ b/packages/mui-material/src/NativeSelect/NativeSelectInput.js @@ -12,17 +12,17 @@ import rootShouldForwardProp from '../styles/rootShouldForwardProp'; import inputAdornmentClasses from '../InputAdornment/inputAdornmentClasses'; const useUtilityClasses = (ownerState) => { - const { classes, variant, disabled, multiple, open, error } = ownerState; + const { classes, variant, disabled, multiple, error } = ownerState; const slots = { 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); }; -export const StyledSelectSelect = styled('select', { +const StyledSelectSelect = styled('select', { name: 'MuiNativeSelect', })(({ theme }) => ({ // Reset @@ -130,7 +130,7 @@ const NativeSelectSelect = styled(StyledSelectSelect, { }, })({}); -export const StyledSelectIcon = styled('svg', { +const StyledSelectIcon = styled('svg', { name: 'MuiNativeSelect', })(({ theme }) => ({ // 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, }, variants: [ - { - props: ({ ownerState }) => ownerState.open, - style: { - transform: 'rotate(180deg)', - }, - }, { props: { variant: 'filled', @@ -176,11 +170,7 @@ const NativeSelectIcon = styled(StyledSelectIcon, { slot: 'Icon', overridesResolver: (props, styles) => { const { ownerState } = props; - return [ - styles.icon, - ownerState.variant && styles[`icon${capitalize(ownerState.variant)}`], - ownerState.open && styles.iconOpen, - ]; + return [styles.icon, ownerState.variant && styles[`icon${capitalize(ownerState.variant)}`]]; }, })({}); diff --git a/packages/mui-material/src/NativeSelect/nativeSelectClasses.ts b/packages/mui-material/src/NativeSelect/nativeSelectClasses.ts index 8301846b3c6a87..5b7d3ed2e66218 100644 --- a/packages/mui-material/src/NativeSelect/nativeSelectClasses.ts +++ b/packages/mui-material/src/NativeSelect/nativeSelectClasses.ts @@ -18,7 +18,7 @@ export interface NativeSelectClasses { disabled: string; /** Styles applied to the icon component. */ 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; /** Styles applied to the icon component if `variant="filled"`. */ iconFilled: string; diff --git a/packages/mui-material/src/Select/Select.test.js b/packages/mui-material/src/Select/Select.test.js index de50eb1454d97c..1459a6da238e3c 100644 --- a/packages/mui-material/src/Select/Select.test.js +++ b/packages/mui-material/src/Select/Select.test.js @@ -2774,6 +2774,41 @@ describe(' + + + + , + ); + + expect(container.querySelector(`.${classes.select}`)).not.toHaveComputedStyle({ + textTransform: 'uppercase', + }); + expect(container.querySelector(`.${classes.icon}`)).not.toHaveComputedStyle({ + textTransform: 'uppercase', + }); + }); + describe('prop: input', () => { it('merges `ref` of `Select` and `input`', () => { const Input = React.forwardRef(function Input(props, ref) { diff --git a/packages/mui-material/src/Select/SelectInput.js b/packages/mui-material/src/Select/SelectInput.js index 806baf3def7faa..30be7500d2ea86 100644 --- a/packages/mui-material/src/Select/SelectInput.js +++ b/packages/mui-material/src/Select/SelectInput.js @@ -9,8 +9,9 @@ import refType from '@mui/utils/refType'; import useTimeout from '@mui/utils/useTimeout'; import ownerDocument from '../utils/ownerDocument'; import Menu from '../Menu/Menu'; -import { StyledSelectSelect, StyledSelectIcon } from '../NativeSelect/NativeSelectInput'; import { isFilled } from '../InputBase/utils'; +import inputBaseClasses from '../InputBase/inputBaseClasses'; +import inputAdornmentClasses from '../InputAdornment/inputAdornmentClasses'; import { styled } from '../zero-styled'; import slotShouldForwardProp from '../styles/slotShouldForwardProp'; import useEnhancedEffect from '../utils/useEnhancedEffect'; @@ -72,7 +73,7 @@ function isMouseEventInsideElement(event, element) { ); } -const SelectSelect = styled(StyledSelectSelect, { +const SelectSelect = styled('div', { name: 'MuiSelect', slot: 'Select', overridesResolver: (props, styles) => { @@ -85,7 +86,29 @@ const SelectSelect = styled(StyledSelectSelect, { { [`&.${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 [`&.${selectClasses.select}`]: { height: 'auto', // Resets for multiple select with chips @@ -94,16 +117,108 @@ const SelectSelect = styled(StyledSelectSelect, { whiteSpace: 'nowrap', 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', slot: 'Icon', overridesResolver: (props, styles) => { const { ownerState } = props; 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', { shouldForwardProp: (prop) => slotShouldForwardProp(prop) && prop !== 'classes', @@ -838,7 +953,6 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) { return ( Date: Mon, 26 Oct 2026 11:12:42 +0100 Subject: [PATCH 2/3] [native select] Remove the iconOpen class --- docs/pages/material-ui/api/native-select.json | 6 ------ docs/translations/api-docs/native-select/native-select.json | 3 --- .../mui-material/src/NativeSelect/nativeSelectClasses.ts | 3 --- 3 files changed, 12 deletions(-) diff --git a/docs/pages/material-ui/api/native-select.json b/docs/pages/material-ui/api/native-select.json index fa37463e4aae8f..9c24bd97be36a6 100644 --- a/docs/pages/material-ui/api/native-select.json +++ b/docs/pages/material-ui/api/native-select.json @@ -67,12 +67,6 @@ "description": "Styles applied to the icon component if `variant=\"filled\"`.", "isGlobal": false }, - { - "key": "iconOpen", - "className": "MuiNativeSelect-iconOpen", - "description": "This class is never applied, a native select doesn't expose whether its popup is open.", - "isGlobal": false - }, { "key": "iconOutlined", "className": "MuiNativeSelect-iconOutlined", diff --git a/docs/translations/api-docs/native-select/native-select.json b/docs/translations/api-docs/native-select/native-select.json index 2d6ff6fa5f2bf4..2ade4be463446f 100644 --- a/docs/translations/api-docs/native-select/native-select.json +++ b/docs/translations/api-docs/native-select/native-select.json @@ -47,9 +47,6 @@ "nodeName": "the icon component", "conditions": "variant=\"filled\"" }, - "iconOpen": { - "description": "This class is never applied, a native select doesn't expose whether its popup is open." - }, "iconOutlined": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the icon component", diff --git a/packages/mui-material/src/NativeSelect/nativeSelectClasses.ts b/packages/mui-material/src/NativeSelect/nativeSelectClasses.ts index 5b7d3ed2e66218..80316d7fd6d25a 100644 --- a/packages/mui-material/src/NativeSelect/nativeSelectClasses.ts +++ b/packages/mui-material/src/NativeSelect/nativeSelectClasses.ts @@ -18,8 +18,6 @@ export interface NativeSelectClasses { disabled: string; /** Styles applied to the icon component. */ icon: string; - /** This class is never applied, a native select doesn't expose whether its popup is open. */ - iconOpen: string; /** Styles applied to the icon component if `variant="filled"`. */ iconFilled: string; /** Styles applied to the icon component if `variant="outlined"`. */ @@ -47,7 +45,6 @@ const nativeSelectClasses: NativeSelectClasses = generateUtilityClasses('MuiNati 'standard', 'disabled', 'icon', - 'iconOpen', 'iconFilled', 'iconOutlined', 'iconStandard', From 0906809a10cfe12d867ab0c66738772cbc046de3 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 26 Oct 2026 11:45:49 +0100 Subject: [PATCH 3/3] [select] Keep extending the NativeSelect styles --- .../src/NativeSelect/NativeSelectInput.js | 4 +- .../mui-material/src/Select/Select.test.js | 38 +----- .../mui-material/src/Select/SelectInput.js | 121 ++---------------- 3 files changed, 15 insertions(+), 148 deletions(-) diff --git a/packages/mui-material/src/NativeSelect/NativeSelectInput.js b/packages/mui-material/src/NativeSelect/NativeSelectInput.js index d08e23517b449e..e150553a7d171c 100644 --- a/packages/mui-material/src/NativeSelect/NativeSelectInput.js +++ b/packages/mui-material/src/NativeSelect/NativeSelectInput.js @@ -22,7 +22,7 @@ const useUtilityClasses = (ownerState) => { return composeClasses(slots, getNativeSelectUtilityClasses, classes); }; -const StyledSelectSelect = styled('select', { +export const StyledSelectSelect = styled('select', { name: 'MuiNativeSelect', })(({ theme }) => ({ // Reset @@ -130,7 +130,7 @@ const NativeSelectSelect = styled(StyledSelectSelect, { }, })({}); -const StyledSelectIcon = styled('svg', { +export const StyledSelectIcon = styled('svg', { name: 'MuiNativeSelect', })(({ theme }) => ({ // We use a position absolute over a flexbox in order to forward the pointer events diff --git a/packages/mui-material/src/Select/Select.test.js b/packages/mui-material/src/Select/Select.test.js index 1459a6da238e3c..3f00a1fd016584 100644 --- a/packages/mui-material/src/Select/Select.test.js +++ b/packages/mui-material/src/Select/Select.test.js @@ -2774,39 +2774,11 @@ describe(' - - - - , - ); - - expect(container.querySelector(`.${classes.select}`)).not.toHaveComputedStyle({ - textTransform: 'uppercase', - }); - expect(container.querySelector(`.${classes.icon}`)).not.toHaveComputedStyle({ - textTransform: 'uppercase', - }); + it('should rotate the icon when open', () => { + const { container } = render(