Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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,70 @@
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');

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

// Blur the sections container
fireEvent.blur(view.getSectionsContainer());

expect(getFieldInputRoot()).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');
fireEvent.blur(view.getSectionsContainer());

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');
fireEvent.blur(view.getSectionsContainer());

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: '0/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,59 @@
import { DateField } from '@mui/x-date-pickers/DateField';
import { describeAdapters , getTextbox , getFieldInputRoot } from 'test/utils/pickers';
import { fireEvent } from '@mui/internal-test-utils';
import { fireUserEvent } from 'test/utils/fireUserEvent';

// 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 }) => {
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');
view.pressKey(0, '0');
view.pressKey(0, '0');

// 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]');

// 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 () => {
const view = renderWithProps({ enableAccessibleFieldDOMStructure: false }); // default format is numeric, e.g. MM/DD/YYYY

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

// Simulate typing into the month section to make it invalid: "00"
// Replace placeholder for a month while keeping placeholders for the rest
// Step 1: type single zero
fireEvent.change(input, { target: { value: '0/DD/YYYY' } });
// Step 2: type the second zero
fireEvent.change(input, { target: { value: '00/DD/YYYY' } });

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

// Move to year and spin using keypress
await view.selectSectionAsync('year');
fireUserEvent.keyPress(input, { key: 'ArrowUp' });
fireUserEvent.keyPress(input, { key: 'ArrowDown' });

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,65 @@ export const useFieldState = <
[state.sections],
);

// Keep invalid state sticky while sections still represent an invalid date.
// This prevents a transient clearing of the error during rapid keyboard updates (e.g. holding ArrowUp/Down)
// where `value` might not be immediately updated but the visible sections still form an invalid date.
const hasInvalidSectionValue = React.useMemo(() => {
// If all sections are empty, we don't consider it invalid.
if (state.sections.every((s) => s.value === '')) {
return false;
}

// Consider sections invalid only when all are filled but they do not form a valid date.
const allFilled = state.sections.every((s) => s.value !== '');
if (!allFilled) {
return false;
}

// If no section is active (e.g., range field with no focused section),
// defer to regular validator to avoid mixing both dates' sections.
if (activeSectionIndex == null) {
return false;
}

// Build a date from the current sections and check validity using the active date's sections only.
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(() => {
// Only check on blur: when no section is active.
if (activeSectionIndex != null) {
return false;
}

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

// Partially filled means at least one section filled and at least one empty.
// If all sections are empty, we don't consider it invalid on blur.
return someFilled && someEmpty;
}, [state.sections, activeSectionIndex]);

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;
}

// If validation reports an error, or the current sections compose to an invalid date,
// or the field is blurred with partially filled sections, keep the field in error state.
return hasValidationError || hasInvalidSectionValue || hasPartiallyFilledSectionsOnBlur;
}, [hasValidationError, hasInvalidSectionValue, hasPartiallyFilledSectionsOnBlur, errorProp]);

const publishValue = (newValue: TValue) => {
const context: FieldChangeHandlerContext<TError> = {
validationError: validator({
Expand Down
Loading