From 3c5928914e2412f1618b1bb41c2aa471fc2ff2a0 Mon Sep 17 00:00:00 2001 From: Vladexy88x Date: Tue, 16 Jun 2026 14:21:10 +0300 Subject: [PATCH] [InputBase] Dim native placeholder for empty date/time inputs Track the empty state of date-like inputs (date, datetime-local, month, time, week) and apply an `inputEmptyDateLike` state class so the native `::-webkit-datetime-edit` placeholder is greyed out like a regular text placeholder instead of appearing filled. Co-Authored-By: Claude Opus 4.8 --- docs/pages/material-ui/api/input-base.json | 6 +++ docs/pages/material-ui/api/input.json | 6 +++ .../pages/material-ui/api/outlined-input.json | 6 +++ .../api-docs/input-base/input-base.json | 5 +++ docs/translations/api-docs/input/input.json | 5 +++ .../outlined-input/outlined-input.json | 5 +++ .../mui-material/src/Input/inputClasses.ts | 2 + .../mui-material/src/InputBase/InputBase.js | 35 ++++++++++++++- .../src/InputBase/InputBase.test.js | 43 ++++++++++++++++++ .../src/InputBase/inputBaseClasses.ts | 3 ++ .../src/OutlinedInput/outlinedInputClasses.ts | 2 + .../fixtures/TextField/EmptyDateTextField.js | 44 +++++++++++++++++++ 12 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 test/regressions/fixtures/TextField/EmptyDateTextField.js diff --git a/docs/pages/material-ui/api/input-base.json b/docs/pages/material-ui/api/input-base.json index 8d0991f900ff4b..17033993e6c7a2 100644 --- a/docs/pages/material-ui/api/input-base.json +++ b/docs/pages/material-ui/api/input-base.json @@ -131,6 +131,12 @@ "description": "Styles applied to the input element.", "isGlobal": false }, + { + "key": "inputEmptyDateLike", + "className": "MuiInputBase-inputEmptyDateLike", + "description": "State class applied to the input element if it is an empty date/time input (`type=\"date\"`, `\"datetime-local\"`, `\"month\"`, `\"time\"`, or `\"week\"`).", + "isGlobal": false + }, { "key": "inputTypeSearch", "className": "MuiInputBase-inputTypeSearch", diff --git a/docs/pages/material-ui/api/input.json b/docs/pages/material-ui/api/input.json index 6d0360df0ae25e..3bae6aefb8ec15 100644 --- a/docs/pages/material-ui/api/input.json +++ b/docs/pages/material-ui/api/input.json @@ -99,6 +99,12 @@ "description": "Styles applied to the input element.", "isGlobal": false }, + { + "key": "inputEmptyDateLike", + "className": "MuiInput-inputEmptyDateLike", + "description": "State class applied to the input element if it is an empty date/time input (`type=\"date\"`, `\"datetime-local\"`, `\"month\"`, `\"time\"`, or `\"week\"`).", + "isGlobal": false + }, { "key": "inputTypeSearch", "className": "MuiInput-inputTypeSearch", diff --git a/docs/pages/material-ui/api/outlined-input.json b/docs/pages/material-ui/api/outlined-input.json index 501536cb394e28..566008f2b59690 100644 --- a/docs/pages/material-ui/api/outlined-input.json +++ b/docs/pages/material-ui/api/outlined-input.json @@ -109,6 +109,12 @@ "description": "Styles applied to the input element.", "isGlobal": false }, + { + "key": "inputEmptyDateLike", + "className": "MuiOutlinedInput-inputEmptyDateLike", + "description": "State class applied to the input element if it is an empty date/time input (`type=\"date\"`, `\"datetime-local\"`, `\"month\"`, `\"time\"`, or `\"week\"`).", + "isGlobal": false + }, { "key": "inputTypeSearch", "className": "MuiOutlinedInput-inputTypeSearch", diff --git a/docs/translations/api-docs/input-base/input-base.json b/docs/translations/api-docs/input-base/input-base.json index c5347c1009807b..5dba079b71ec21 100644 --- a/docs/translations/api-docs/input-base/input-base.json +++ b/docs/translations/api-docs/input-base/input-base.json @@ -137,6 +137,11 @@ "conditions": "hiddenLabel={true}" }, "input": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the input element" }, + "inputEmptyDateLike": { + "description": "State class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the input element", + "conditions": "it is an empty date/time input (type=\"date\", \"datetime-local\", \"month\", \"time\", or \"week\")" + }, "inputTypeSearch": { "description": "" }, "multiline": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", diff --git a/docs/translations/api-docs/input/input.json b/docs/translations/api-docs/input/input.json index 558c584bafb5f8..738cc536016f6f 100644 --- a/docs/translations/api-docs/input/input.json +++ b/docs/translations/api-docs/input/input.json @@ -114,6 +114,11 @@ "conditions": "fullWidth={true}" }, "input": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the input element" }, + "inputEmptyDateLike": { + "description": "State class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the input element", + "conditions": "it is an empty date/time input (type=\"date\", \"datetime-local\", \"month\", \"time\", or \"week\")" + }, "inputTypeSearch": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the input element", diff --git a/docs/translations/api-docs/outlined-input/outlined-input.json b/docs/translations/api-docs/outlined-input/outlined-input.json index 5bb992a2fa7e10..0d5cc2b7048308 100644 --- a/docs/translations/api-docs/outlined-input/outlined-input.json +++ b/docs/translations/api-docs/outlined-input/outlined-input.json @@ -115,6 +115,11 @@ "conditions": "the component is focused" }, "input": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the input element" }, + "inputEmptyDateLike": { + "description": "State class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the input element", + "conditions": "it is an empty date/time input (type=\"date\", \"datetime-local\", \"month\", \"time\", or \"week\")" + }, "inputTypeSearch": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the input element", diff --git a/packages/mui-material/src/Input/inputClasses.ts b/packages/mui-material/src/Input/inputClasses.ts index 9793fd37066312..0b572fa2b5cacd 100644 --- a/packages/mui-material/src/Input/inputClasses.ts +++ b/packages/mui-material/src/Input/inputClasses.ts @@ -27,6 +27,8 @@ export interface InputClasses { input: string; /** Styles applied to the input element if `type="search"`. */ inputTypeSearch: string; + /** State class applied to the input element if it is an empty date/time input (`type="date"`, `"datetime-local"`, `"month"`, `"time"`, or `"week"`). */ + inputEmptyDateLike: string; } export type InputClassKey = keyof InputClasses; diff --git a/packages/mui-material/src/InputBase/InputBase.js b/packages/mui-material/src/InputBase/InputBase.js index 659fb7ebd297dc..0d0211b12f721d 100644 --- a/packages/mui-material/src/InputBase/InputBase.js +++ b/packages/mui-material/src/InputBase/InputBase.js @@ -24,6 +24,12 @@ import { getTransitionStyles } from '../transitions/utils'; const MUI_AUTO_FILL = 'mui-auto-fill'; const MUI_AUTO_FILL_CANCEL = 'mui-auto-fill-cancel'; +// Native date/time inputs render their value (and, when empty, a placeholder) +// through the `::-webkit-datetime-edit` pseudo-elements using the input's color. +// As a result an empty field looks identical to a filled one. We track the empty +// state to grey out the placeholder, matching the regular text placeholder. +const DATE_LIKE_TYPES = ['date', 'datetime-local', 'month', 'time', 'week']; + export const rootOverridesResolver = (props, styles) => { const { ownerState } = props; @@ -58,6 +64,7 @@ const useUtilityClasses = (ownerState) => { formControl, fullWidth, hiddenLabel, + isEmptyDateLikeInput, multiline, readOnly, size, @@ -84,6 +91,7 @@ const useUtilityClasses = (ownerState) => { 'input', disabled && 'disabled', type === 'search' && 'inputTypeSearch', + isEmptyDateLikeInput && 'inputEmptyDateLike', readOnly && 'readOnly', ], }; @@ -162,6 +170,14 @@ export const InputBaseInput = styled('input', { : { opacity: light ? 0.42 : 0.5, }; + // Applied to the `::-webkit-datetime-edit` of an empty date/time input so its + // placeholder is dimmed like a regular text placeholder instead of looking filled. + const datetimePlaceholder = { + ...placeholderVisible, + ...getTransitionStyles(theme, 'opacity', { + duration: theme.transitions.duration.shorter, + }), + }; return { font: 'inherit', @@ -242,6 +258,12 @@ export const InputBaseInput = styled('input', { MozAppearance: 'textfield', // Improve type search style. }, }, + { + props: ({ ownerState }) => ownerState.isEmptyDateLikeInput, + style: { + '&::-webkit-datetime-edit': datetimePlaceholder, + }, + }, ], }; }), @@ -305,6 +327,10 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { const value = inputPropsProp.value != null ? inputPropsProp.value : valueProp; const { current: isControlled } = React.useRef(value != null); + const isDateLikeType = DATE_LIKE_TYPES.includes(type); + // Only meaningful for date/time inputs, where it dims the native placeholder. + const [empty, setEmpty] = React.useState(() => !isFilled({ value, defaultValue }, true)); + const inputRef = React.useRef(); const handleInputRefWarning = React.useCallback((instance) => { if (process.env.NODE_ENV !== 'production') { @@ -364,15 +390,19 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { const checkDirty = React.useCallback( (obj) => { - if (isFilled(obj)) { + const filled = isFilled(obj); + if (filled) { if (onFilled) { onFilled(); } } else if (onEmpty) { onEmpty(); } + if (isDateLikeType) { + setEmpty(!filled); + } }, - [onFilled, onEmpty], + [onFilled, onEmpty, isDateLikeType], ); useEnhancedEffect(() => { @@ -535,6 +565,7 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { formControl: muiFormControl, fullWidth, hiddenLabel: fcs.hiddenLabel, + isEmptyDateLikeInput: isDateLikeType && empty, multiline, size: fcs.size, startAdornment, diff --git a/packages/mui-material/src/InputBase/InputBase.test.js b/packages/mui-material/src/InputBase/InputBase.test.js index b2685dbfc18323..d2da31a333c010 100644 --- a/packages/mui-material/src/InputBase/InputBase.test.js +++ b/packages/mui-material/src/InputBase/InputBase.test.js @@ -150,6 +150,49 @@ describe('', () => { }); }); + describe('empty date/time inputs', () => { + ['date', 'datetime-local', 'month', 'time', 'week'].forEach((type) => { + it(`applies the inputEmptyDateLike class to an empty type="${type}" input`, () => { + const { container } = render( {}} />); + expect(container.querySelector('input')).to.have.class(classes.inputEmptyDateLike); + }); + }); + + it('does not apply the class when the date input has a value', () => { + const { container } = render( + {}} />, + ); + expect(container.querySelector('input')).not.to.have.class(classes.inputEmptyDateLike); + }); + + it('does not apply the class to a non-date input even when empty', () => { + const { container } = render( {}} />); + expect(container.querySelector('input')).not.to.have.class(classes.inputEmptyDateLike); + }); + + it('toggles the class as the controlled value changes', () => { + const { container, setProps } = render( + {}} />, + ); + expect(container.querySelector('input')).to.have.class(classes.inputEmptyDateLike); + + setProps({ value: '2020-01-01' }); + expect(container.querySelector('input')).not.to.have.class(classes.inputEmptyDateLike); + + setProps({ value: '' }); + expect(container.querySelector('input')).to.have.class(classes.inputEmptyDateLike); + }); + + it('toggles the class for uncontrolled inputs on change', () => { + const { container } = render(); + const input = container.querySelector('input'); + expect(input).to.have.class(classes.inputEmptyDateLike); + + fireEvent.change(input, { target: { value: '2020-01-01' } }); + expect(input).not.to.have.class(classes.inputEmptyDateLike); + }); + }); + it('should fire event callbacks', () => { const handleChange = spy(); const handleFocus = spy(); diff --git a/packages/mui-material/src/InputBase/inputBaseClasses.ts b/packages/mui-material/src/InputBase/inputBaseClasses.ts index f2ce851780c308..ee5c3e1eea8981 100644 --- a/packages/mui-material/src/InputBase/inputBaseClasses.ts +++ b/packages/mui-material/src/InputBase/inputBaseClasses.ts @@ -31,6 +31,8 @@ export interface InputBaseClasses { /** Styles applied to the input element. */ input: string; inputTypeSearch: string; + /** State class applied to the input element if it is an empty date/time input (`type="date"`, `"datetime-local"`, `"month"`, `"time"`, or `"week"`). */ + inputEmptyDateLike: string; } export type InputBaseClassKey = keyof InputBaseClasses; @@ -55,6 +57,7 @@ const inputBaseClasses: InputBaseClasses = generateUtilityClasses('MuiInputBase' 'readOnly', 'input', 'inputTypeSearch', + 'inputEmptyDateLike', ]); export default inputBaseClasses; diff --git a/packages/mui-material/src/OutlinedInput/outlinedInputClasses.ts b/packages/mui-material/src/OutlinedInput/outlinedInputClasses.ts index 7688b7922e7bc8..237f404c169023 100644 --- a/packages/mui-material/src/OutlinedInput/outlinedInputClasses.ts +++ b/packages/mui-material/src/OutlinedInput/outlinedInputClasses.ts @@ -27,6 +27,8 @@ export interface OutlinedInputClasses { input: string; /** Styles applied to the input element if `type="search"`. */ inputTypeSearch: string; + /** State class applied to the input element if it is an empty date/time input (`type="date"`, `"datetime-local"`, `"month"`, `"time"`, or `"week"`). */ + inputEmptyDateLike: string; } export type OutlinedInputClassKey = keyof OutlinedInputClasses; diff --git a/test/regressions/fixtures/TextField/EmptyDateTextField.js b/test/regressions/fixtures/TextField/EmptyDateTextField.js new file mode 100644 index 00000000000000..dd4f1d7d8666f5 --- /dev/null +++ b/test/regressions/fixtures/TextField/EmptyDateTextField.js @@ -0,0 +1,44 @@ +import * as React from 'react'; +import TextField from '@mui/material/TextField'; + +const types = ['date', 'datetime-local', 'month', 'time', 'week']; + +// The empty inputs should render a dimmed placeholder, while the filled ones +// render at full color, so the two rows are visually distinguishable. +export default function EmptyDateTextField() { + return ( +
+ {['standard', 'outlined', 'filled'].map((variant) => ( +
+ {types.map((type) => ( + + ))} + {types.map((type) => ( + + ))} +
+ ))} +
+ ); +}