Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { DateField } from '@mui/x-date-pickers/DateField';
import { describeAdapters, getTextbox, getFieldInputRoot } from 'test/utils/pickers';
import { fireEvent } from '@mui/internal-test-utils';

// Tests that on blur, partially filled fields are considered invalid
// while completely empty or fully valid fields remain not invalid.

describeAdapters(
'DateField - partial filling on blur',
DateField,
({ adapter, renderWithProps }) => {
it('marks field invalid on blur when only some sections are filled (accessible DOM)', async () => {
const view = renderWithProps({ enableAccessibleFieldDOMStructure: true });

await view.selectSectionAsync('month');
await view.user.keyboard('0');
await view.user.keyboard('1');

const fieldRoot = getFieldInputRoot();

// While focused and partially filled, it should not be invalid yet
expect(fieldRoot).to.have.attribute('aria-invalid', 'false');

// Blur the sections container to trigger validation in accessible DOM
await view.user.tab();

expect(fieldRoot).to.have.attribute('aria-invalid', 'true');

view.unmount();
});

it('does not mark invalid on blur when all sections are empty (accessible DOM)', async () => {
const view = renderWithProps({ enableAccessibleFieldDOMStructure: true });

// Focus a section then blur without typing
await view.selectSectionAsync('month');
await view.user.tab();

expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'false');

view.unmount();
});

it('does not mark invalid on blur when value is fully valid (accessible DOM)', async () => {
const view = renderWithProps({
enableAccessibleFieldDOMStructure: true,
defaultValue: adapter.date('2025-01-15'),
});

// Focus and blur
await view.selectSectionAsync('month');
await view.user.tab();

expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'false');

view.unmount();
});

it('marks field invalid on blur when only some sections are filled (non-accessible DOM)', async () => {
const view = renderWithProps({ enableAccessibleFieldDOMStructure: false });

await view.selectSectionAsync('month');
const input = getTextbox();

// Partially fill the month: "01/DD/YYYY"
fireEvent.change(input, { target: { value: '01/DD/YYYY' } });

expect(input).to.have.attribute('aria-invalid', 'false');

// Blur the input in non-accessible DOM
fireEvent.blur(input);

expect(input).to.have.attribute('aria-invalid', 'true');

view.unmount();
});
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { DateField } from '@mui/x-date-pickers/DateField';
import { describeAdapters, getTextbox, getFieldInputRoot } from 'test/utils/pickers';

// Regression: invalid state should not temporarily clear during keyboard spin when sections are still invalid
// Reproduction steps covered:
// 1. Type an invalid month ("00")
// 2. Move focus to the year section
// 3. Press ArrowUp/ArrowDown to spin year
// Expectation: aria-invalid remains true while the overall value is still invalid

describeAdapters(
'DateField - sticky invalid state during keyboard spin',
DateField,
({ renderWithProps, adapter }) => {
it('keeps aria-invalid=true while spinning year when month is invalid (accessible DOM)', async () => {
const view = renderWithProps({ enableAccessibleFieldDOMStructure: true }); // default format is numeric, e.g. MM/DD/YYYY

// Make month invalid by typing "00"
await view.selectSectionAsync('month');
await view.user.keyboard('00');
await view.user.tab();

// Should be invalid now
expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'true');

// Move to year and spin
await view.selectSectionAsync('year');
await view.user.keyboard('[ArrowUp][ArrowUp][ArrowDown]');
await view.user.tab();

// Still invalid, must not flash to valid between spins
expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'true');

view.unmount();
});

it('keeps aria-invalid=true while spinning year when month is invalid (non-accessible DOM)', async () => {
// moment and luxon validation seem to not work
if (['luxon', 'moment'].includes(adapter.lib)) {
return;
}

const view = renderWithProps({ enableAccessibleFieldDOMStructure: false }); // default format is numeric, e.g. MM/DD/YYYY

await view.selectSectionAsync('month');
const input = getTextbox();

await view.selectSectionAsync('month');
await view.user.keyboard('0');
await view.user.tab();

expect(input).to.have.attribute('aria-invalid', 'true');

// Move to year and spin using keypress
await view.selectSectionAsync('year');
await view.user.keyboard('[ArrowUp][ArrowUp][ArrowDown]');
await view.user.tab();

expect(input).to.have.attribute('aria-invalid', 'true');

view.unmount();
});
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,6 @@ export const useFieldState = <
onError: internalPropsWithDefaults.onError,
});

const error = React.useMemo(() => {
// only override when `error` is undefined.
// in case of multi input fields, the `error` value is provided externally and will always be defined.
if (errorProp !== undefined) {
return errorProp;
}

return hasValidationError;
}, [hasValidationError, errorProp]);

const localizedDigits = React.useMemo(() => getLocalizedDigits(adapter), [adapter]);

const sectionsValueBoundaries = React.useMemo(
Expand Down Expand Up @@ -209,6 +199,55 @@ export const useFieldState = <
[state.sections],
);

// Keep invalid state "sticky" while sections still represent an invalid date.
const hasInvalidSectionValue = React.useMemo(() => {
if (state.sections.every((s) => s.value === '')) {
return false;
}

const allFilled = state.sections.every((s) => s.value !== '');
if (!allFilled) {
return false;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is duplicated


if (activeSectionIndex == null) {
return false;
}

const activeDateSections = fieldValueManager.getDateSectionsFromValue(
state.sections,
state.sections[activeSectionIndex] as any,
);

const dateFromSections = getDateFromDateSections(
adapter,
activeDateSections as any,
localizedDigits,
);
return !adapter.isValid(dateFromSections);
}, [adapter, fieldValueManager, state.sections, activeSectionIndex, localizedDigits]);

// When the field loses focus (no active section), consider partially filled sections as invalid.
// This enforces that the field must be entirely filled or entirely empty on blur.
const hasPartiallyFilledSectionsOnBlur = React.useMemo(() => {
if (activeSectionIndex != null) {
return false;
}

const someFilled = state.sections.some((s) => s.value !== '');
const someEmpty = state.sections.some((s) => s.value === '');

return someFilled && someEmpty;
}, [state.sections, activeSectionIndex]);

const error = React.useMemo(() => {
if (errorProp !== undefined) {
return errorProp;
}

return hasValidationError || hasInvalidSectionValue || hasPartiallyFilledSectionsOnBlur;
}, [hasValidationError, hasInvalidSectionValue, hasPartiallyFilledSectionsOnBlur, errorProp]);

const publishValue = (newValue: TValue) => {
const context: FieldChangeHandlerContext<TError> = {
validationError: validator({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ export const useFieldV6TextField = <
// Forwarded
...forwardedProps,
error,
'aria-invalid': error,
Copy link
Member

@siriwatknp siriwatknp Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just curious, does this mean introducing new behavior?
Has aria-invalid been used with v6TextField?

clearable: Boolean(clearable && !areAllSectionsEmpty && !readOnly && !disabled),
onBlur: handleContainerBlur,
onClick: handleInputClick,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { DescribeRangeValidationTestSuite } from './describeRangeValidation.type
const testInvalidStatus = (
expectedAnswer: boolean[],
fieldType: 'single-input' | 'multi-input' | 'no-input',
incompleteRange?: boolean,
) => {
const answers =
fieldType === 'multi-input' ? expectedAnswer : [expectedAnswer[0] || expectedAnswer[1]];
Expand All @@ -15,6 +16,10 @@ const testInvalidStatus = (
answers.forEach((answer, index) => {
const fieldRoot = fields[index];

if (incompleteRange && fieldType === 'single-input') {
expect(fieldRoot).to.have.attribute('aria-invalid', 'true');
return;
}
expect(fieldRoot).to.have.attribute('aria-invalid', answer ? 'true' : 'false');
});
};
Expand Down Expand Up @@ -315,15 +320,15 @@ export const testTextFieldRangeValidation: DescribeRangeValidationTestSuite = (

expect(onErrorMock.callCount).to.equal(1);
expect(onErrorMock.lastCall.args[0]).to.deep.equal(['minDate', null]);
testInvalidStatus([true, false], fieldType);
testInvalidStatus([true, false], fieldType, true);

setProps({
value: [adapterToUse.date('2018-03-16'), null],
});

expect(onErrorMock.callCount).to.equal(2);
expect(onErrorMock.lastCall.args[0]).to.deep.equal([null, null]);
testInvalidStatus([false, false], fieldType);
testInvalidStatus([false, false], fieldType, true);
});

it.skipIf(!withDate)('should apply minDate when only second field is filled', () => {
Expand All @@ -338,15 +343,15 @@ export const testTextFieldRangeValidation: DescribeRangeValidationTestSuite = (

expect(onErrorMock.callCount).to.equal(1);
expect(onErrorMock.lastCall.args[0]).to.deep.equal([null, 'minDate']);
testInvalidStatus([false, true], fieldType);
testInvalidStatus([false, true], fieldType, true);

setProps({
value: [null, adapterToUse.date('2018-03-16')],
});

expect(onErrorMock.callCount).to.equal(2);
expect(onErrorMock.lastCall.args[0]).to.deep.equal([null, null]);
testInvalidStatus([false, false], fieldType);
testInvalidStatus([false, false], fieldType, true);
});

it.skipIf(!withDate)('should apply maxDate', () => {
Expand Down Expand Up @@ -449,15 +454,15 @@ export const testTextFieldRangeValidation: DescribeRangeValidationTestSuite = (
);

expect(onErrorMock.callCount).to.equal(0);
testInvalidStatus([false, false], fieldType);
testInvalidStatus([false, false], fieldType, true);

setProps({
value: [adapterToUse.date('2018-02-01T05:00:00'), null],
});

expect(onErrorMock.callCount).to.equal(1);
expect(onErrorMock.lastCall.args[0]).to.deep.equal(['minTime', null]);
testInvalidStatus([true, false], fieldType);
testInvalidStatus([true, false], fieldType, true);
});

it.skipIf(!withTime)('should apply minTime when only second field is filled', () => {
Expand All @@ -471,15 +476,15 @@ export const testTextFieldRangeValidation: DescribeRangeValidationTestSuite = (
);

expect(onErrorMock.callCount).to.equal(0);
testInvalidStatus([false, false], fieldType);
testInvalidStatus([false, false], fieldType, true);

setProps({
value: [null, adapterToUse.date('2018-02-01T05:00:00')],
});

expect(onErrorMock.callCount).to.equal(1);
expect(onErrorMock.lastCall.args[0]).to.deep.equal([null, 'minTime']);
testInvalidStatus([false, true], fieldType);
testInvalidStatus([false, true], fieldType, true);
});

it.skipIf(!withTime)('should apply maxTime', () => {
Expand Down
Loading