From 91f111267518702e253440cef67e883be56f6361 Mon Sep 17 00:00:00 2001 From: dualfroz Date: Sat, 17 Oct 2026 03:50:37 +0200 Subject: [PATCH] [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 6caa6f0..fa37463 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 99344b7..2d6ff6f 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 c7b420d..d08e235 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 8301846..5b7d3ed 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 de50eb1..1459a6d 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 806baf3..30be750 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 (