From 142b9fbdec411ad0345c541f77f85897b6102937 Mon Sep 17 00:00:00 2001 From: Anastasia Mastyaeva Date: Mon, 6 Apr 2026 19:32:01 +0300 Subject: [PATCH 1/4] fix(pickers): fire onError when dates are partially filled --- .../{ => tests}/DateRangePicker.test.tsx | 2 +- ...scribeConformance.DateRangePicker.test.tsx | 2 +- ...ialFillValidation.DateRangePicker.test.tsx | 82 +++++++++++++++ ...lidation.MultiInputDateRangeField.test.tsx | 99 +++++++++++++++++++ ...idation.SingleInputDateRangeField.test.tsx | 82 +++++++++++++++ .../useMultiInputRangeField.ts | 35 ++++++- .../useTextFieldProps.ts | 17 +++- .../src/internals/utils/valueManagers.ts | 12 +++ .../src/validation/validateDateRange.ts | 4 +- .../src/validation/validateDateTimeRange.ts | 4 +- .../blurPartialFilling.DateField.test.tsx | 5 - .../invalidStateKeyboard.DateField.test.tsx | 6 -- .../partialFillValidation.DateField.test.tsx | 71 +++++++++++++ .../partialFillValidation.DatePicker.test.tsx | 69 +++++++++++++ ...rtialFillValidation.DateTimeField.test.tsx | 77 +++++++++++++++ .../partialFillValidation.TimeField.test.tsx | 71 +++++++++++++ .../internals/components/PickerProvider.tsx | 8 ++ .../hooks/useField/useField.types.ts | 2 + .../internals/hooks/useField/useFieldState.ts | 43 +++++--- .../usePicker/hooks/useValueAndOpenStates.ts | 8 +- .../internals/hooks/usePicker/usePicker.tsx | 38 +++++++ .../src/internals/utils/valueManagers.ts | 4 + .../x-date-pickers/src/models/validation.ts | 7 +- .../src/validation/useValidation.ts | 35 +++++-- .../src/validation/validateDate.ts | 5 + .../src/validation/validateDateTime.ts | 4 +- .../src/validation/validateTime.ts | 5 + 27 files changed, 749 insertions(+), 48 deletions(-) rename packages/x-date-pickers-pro/src/DateRangePicker/{ => tests}/DateRangePicker.test.tsx (98%) rename packages/x-date-pickers-pro/src/DateRangePicker/{ => tests}/describeConformance.DateRangePicker.test.tsx (98%) create mode 100644 packages/x-date-pickers-pro/src/DateRangePicker/tests/partialFillValidation.DateRangePicker.test.tsx create mode 100644 packages/x-date-pickers-pro/src/MultiInputDateRangeField/tests/partialFillValidation.MultiInputDateRangeField.test.tsx create mode 100644 packages/x-date-pickers-pro/src/SingleInputDateRangeField/tests/partialFillValidation.SingleInputDateRangeField.test.tsx create mode 100644 packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx create mode 100644 packages/x-date-pickers/src/DatePicker/tests/partialFillValidation.DatePicker.test.tsx create mode 100644 packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx create mode 100644 packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx diff --git a/packages/x-date-pickers-pro/src/DateRangePicker/DateRangePicker.test.tsx b/packages/x-date-pickers-pro/src/DateRangePicker/tests/DateRangePicker.test.tsx similarity index 98% rename from packages/x-date-pickers-pro/src/DateRangePicker/DateRangePicker.test.tsx rename to packages/x-date-pickers-pro/src/DateRangePicker/tests/DateRangePicker.test.tsx index be500a020510e..c2f9999a676ff 100644 --- a/packages/x-date-pickers-pro/src/DateRangePicker/DateRangePicker.test.tsx +++ b/packages/x-date-pickers-pro/src/DateRangePicker/tests/DateRangePicker.test.tsx @@ -8,7 +8,7 @@ import { stubMatchMedia, } from 'test/utils/pickers'; import { pickerPopperClasses } from '@mui/x-date-pickers/internals'; -import { MultiInputDateRangeField } from '../MultiInputDateRangeField'; +import { MultiInputDateRangeField } from '../../MultiInputDateRangeField'; describe('', () => { const { render } = createPickerRenderer(); diff --git a/packages/x-date-pickers-pro/src/DateRangePicker/describeConformance.DateRangePicker.test.tsx b/packages/x-date-pickers-pro/src/DateRangePicker/tests/describeConformance.DateRangePicker.test.tsx similarity index 98% rename from packages/x-date-pickers-pro/src/DateRangePicker/describeConformance.DateRangePicker.test.tsx rename to packages/x-date-pickers-pro/src/DateRangePicker/tests/describeConformance.DateRangePicker.test.tsx index 41f4126e4f9a9..6430ac2b77fa8 100644 --- a/packages/x-date-pickers-pro/src/DateRangePicker/describeConformance.DateRangePicker.test.tsx +++ b/packages/x-date-pickers-pro/src/DateRangePicker/tests/describeConformance.DateRangePicker.test.tsx @@ -1,6 +1,6 @@ import { DateRangePicker } from '@mui/x-date-pickers-pro/DateRangePicker'; import { createPickerRenderer } from 'test/utils/pickers'; -import { describeConformance } from 'test/utils/describeConformance'; +import { describeConformance } from 'test/utils/describeConformance.ts'; describe(' - Describe Conformance', () => { const { render } = createPickerRenderer(); diff --git a/packages/x-date-pickers-pro/src/DateRangePicker/tests/partialFillValidation.DateRangePicker.test.tsx b/packages/x-date-pickers-pro/src/DateRangePicker/tests/partialFillValidation.DateRangePicker.test.tsx new file mode 100644 index 0000000000000..6ebddbad364ea --- /dev/null +++ b/packages/x-date-pickers-pro/src/DateRangePicker/tests/partialFillValidation.DateRangePicker.test.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { spy } from 'sinon'; +import { DateRangePicker } from '@mui/x-date-pickers-pro/DateRangePicker'; +import { screen } from '@mui/internal-test-utils'; +import { createPickerRenderer, getFieldInputRoot } from 'test/utils/pickers'; + +describe(' - partiallyFilledDate validation', () => { + const { render } = createPickerRenderer(); + + it('should call onError with partiallyFilledDate for start when start is partially filled', async () => { + const onError = spy(); + const { user } = render(); + + const month = screen.getAllByRole('spinbutton', { name: 'Month' })[0]; // start month + await user.click(month); + await user.keyboard('01'); + + expect(onError.callCount).to.equal(1); + expect(onError.lastCall.args[0]).to.deep.equal(['partiallyFilledDate', null]); + }); + + it('should call onError with partiallyFilledDate for end when end is partially filled', async () => { + const onError = spy(); + const { user } = render(); + + const months = screen.getAllByRole('spinbutton', { name: 'Month' }); + const endMonth = months[1]; + await user.click(endMonth); + await user.keyboard('01'); + + expect(onError.callCount).to.equal(1); + expect(onError.lastCall.args[0]).to.deep.equal([null, 'partiallyFilledDate']); + }); + + it('should call onError with null when partially filled start is fully cleared', async () => { + const onError = spy(); + const { user } = render(); + + const month = screen.getAllByRole('spinbutton', { name: 'Month' })[0]; + await user.click(month); + await user.keyboard('01'); + expect(onError.lastCall.args[0]).to.deep.equal(['partiallyFilledDate', null]); + + await user.click(month); + await user.keyboard('{Control>}a{/Control}'); + await user.keyboard('{Delete}'); + + expect(onError.lastCall.args[0]).to.deep.equal([null, null]); + }); + + it('should show field as invalid when start is partially filled', async () => { + const { user } = render(); + + const month = screen.getAllByRole('spinbutton', { name: 'Month' })[0]; + await user.click(month); + await user.keyboard('01'); + + expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'true'); + }); + + it('should return invalidDate error when start is fully filled but is invalid', async () => { + const onError = spy(); + const { user } = render(); + + const months = screen.getAllByRole('spinbutton', { name: 'Month' }); + const days = screen.getAllByRole('spinbutton', { name: 'Day' }); + const years = screen.getAllByRole('spinbutton', { name: 'Year' }); + + await user.click(months[0]); + await user.keyboard('02'); + await user.click(days[0]); + await user.keyboard('30'); + + expect(onError.lastCall.args[0]).to.deep.equal(['partiallyFilledDate', null]); + + await user.click(years[0]); + await user.keyboard('2024'); + + expect(onError.lastCall.args[0][0]).not.to.equal('partiallyFilledDate'); + expect(onError.lastCall.args[0][0]).to.equal('invalidDate'); + }); +}); diff --git a/packages/x-date-pickers-pro/src/MultiInputDateRangeField/tests/partialFillValidation.MultiInputDateRangeField.test.tsx b/packages/x-date-pickers-pro/src/MultiInputDateRangeField/tests/partialFillValidation.MultiInputDateRangeField.test.tsx new file mode 100644 index 0000000000000..f85073347cd1f --- /dev/null +++ b/packages/x-date-pickers-pro/src/MultiInputDateRangeField/tests/partialFillValidation.MultiInputDateRangeField.test.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { spy } from 'sinon'; +import { MultiInputDateRangeField } from '@mui/x-date-pickers-pro/MultiInputDateRangeField'; +import { screen } from '@mui/internal-test-utils'; +import { createPickerRenderer } from 'test/utils/pickers'; + +describe(' - partiallyFilledDate validation', () => { + const { render } = createPickerRenderer(); + + it('should call onError with partiallyFilledDate for start when start is partially filled', async () => { + const onError = spy(); + const { user } = render( + , + ); + + const months = screen.getAllByRole('spinbutton', { name: 'Month' }); + await user.click(months[0]); // start month + await user.keyboard('01'); + + expect(onError.callCount).to.equal(1); + expect(onError.lastCall.args[0]).to.deep.equal(['partiallyFilledDate', null]); + }); + + it('should call onError with partiallyFilledDate for end when end is partially filled', async () => { + const onError = spy(); + const { user } = render( + , + ); + + const months = screen.getAllByRole('spinbutton', { name: 'Month' }); + await user.click(months[1]); // end month + await user.keyboard('01'); + + expect(onError.callCount).to.equal(1); + expect(onError.lastCall.args[0]).to.deep.equal([null, 'partiallyFilledDate']); + }); + + it('should call onError with null when partially filled start is fully cleared', async () => { + const onError = spy(); + const { user } = render( + , + ); + + const months = screen.getAllByRole('spinbutton', { name: 'Month' }); + await user.click(months[0]); + await user.keyboard('01'); + expect(onError.lastCall.args[0]).to.deep.equal(['partiallyFilledDate', null]); + + await user.click(months[0]); + await user.keyboard('{Control>}a{/Control}'); + await user.keyboard('{Delete}'); + + expect(onError.lastCall.args[0]).to.deep.equal([null, null]); + }); + + it('should show start field as invalid when start is partially filled', async () => { + const { user } = render(); + + const months = screen.getAllByRole('spinbutton', { name: 'Month' }); + await user.click(months[0]); + await user.keyboard('01'); + + const roots = screen.getAllByRole('group'); + expect(roots[0]).to.have.attribute('aria-invalid', 'true'); + expect(roots[1]).to.have.attribute('aria-invalid', 'false'); + }); + + it('should not show fields as invalid for empty fields', async () => { + render(); + + const roots = screen.getAllByRole('group'); + expect(roots[0]).to.have.attribute('aria-invalid', 'false'); + expect(roots[1]).to.have.attribute('aria-invalid', 'false'); + }); + + it('should return invalidDate error when start is fully filled but date is invalid', async () => { + const onError = spy(); + const { user } = render( + , + ); + + const months = screen.getAllByRole('spinbutton', { name: 'Month' }); + const days = screen.getAllByRole('spinbutton', { name: 'Day' }); + const years = screen.getAllByRole('spinbutton', { name: 'Year' }); + + await user.click(months[0]); + await user.keyboard('02'); + await user.click(days[0]); + await user.keyboard('30'); + + expect(onError.lastCall.args[0]).to.deep.equal(['partiallyFilledDate', null]); + + await user.click(years[0]); + await user.keyboard('2024'); + + expect(onError.lastCall.args[0][0]).not.to.equal('partiallyFilledDate'); + expect(onError.lastCall.args[0][0]).to.equal('invalidDate'); + }); +}); diff --git a/packages/x-date-pickers-pro/src/SingleInputDateRangeField/tests/partialFillValidation.SingleInputDateRangeField.test.tsx b/packages/x-date-pickers-pro/src/SingleInputDateRangeField/tests/partialFillValidation.SingleInputDateRangeField.test.tsx new file mode 100644 index 0000000000000..271db1e525035 --- /dev/null +++ b/packages/x-date-pickers-pro/src/SingleInputDateRangeField/tests/partialFillValidation.SingleInputDateRangeField.test.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { spy } from 'sinon'; +import { SingleInputDateRangeField } from '@mui/x-date-pickers-pro/SingleInputDateRangeField'; +import { screen } from '@mui/internal-test-utils'; +import { createPickerRenderer, getFieldInputRoot } from 'test/utils/pickers'; + +describe(' - partiallyFilledDate validation', () => { + const { render } = createPickerRenderer(); + + it('should call onError with partiallyFilledDate for start when start is partially filled', async () => { + const onError = spy(); + const { user } = render(); + + const month = screen.getAllByRole('spinbutton', { name: 'Month' })[0]; + await user.click(month); + await user.keyboard('01'); + + expect(onError.callCount).to.equal(1); + expect(onError.lastCall.args[0]).to.deep.equal(['partiallyFilledDate', null]); + }); + + it('should call onError with partiallyFilledDate for end when end is partially filled', async () => { + const onError = spy(); + const { user } = render(); + + const months = screen.getAllByRole('spinbutton', { name: 'Month' }); + const endMonth = months[1]; + await user.click(endMonth); + await user.keyboard('01'); + + expect(onError.callCount).to.equal(1); + expect(onError.lastCall.args[0]).to.deep.equal([null, 'partiallyFilledDate']); + }); + + it('should call onError with null when partially filled start is fully cleared', async () => { + const onError = spy(); + const { user } = render(); + + const month = screen.getAllByRole('spinbutton', { name: 'Month' })[0]; + await user.click(month); + await user.keyboard('01'); + expect(onError.lastCall.args[0]).to.deep.equal(['partiallyFilledDate', null]); + + await user.click(month); + await user.keyboard('{Control>}a{/Control}'); + await user.keyboard('{Delete}'); + + expect(onError.lastCall.args[0]).to.deep.equal([null, null]); + }); + + it('should show field as invalid when start is partially filled', async () => { + const { user } = render(); + + const month = screen.getAllByRole('spinbutton', { name: 'Month' })[0]; + await user.click(month); + await user.keyboard('01'); + + expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'true'); + }); + + it('should return invalidDate error when start is fully filled but date is invalid', async () => { + const onError = spy(); + const { user } = render(); + + const months = screen.getAllByRole('spinbutton', { name: 'Month' }); + const days = screen.getAllByRole('spinbutton', { name: 'Day' }); + const years = screen.getAllByRole('spinbutton', { name: 'Year' }); + + await user.click(months[0]); + await user.keyboard('02'); + await user.click(days[0]); + await user.keyboard('30'); + + expect(onError.lastCall.args[0]).to.deep.equal(['partiallyFilledDate', null]); + + await user.click(years[0]); + await user.keyboard('2024'); + + expect(onError.lastCall.args[0][0]).not.to.equal('partiallyFilledDate'); + expect(onError.lastCall.args[0][0]).to.equal('invalidDate'); + }); +}); diff --git a/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useMultiInputRangeField.ts b/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useMultiInputRangeField.ts index 6c170d83c3196..85ce13543a6ae 100644 --- a/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useMultiInputRangeField.ts +++ b/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useMultiInputRangeField.ts @@ -1,10 +1,13 @@ 'use client'; +import * as React from 'react'; import { PickerManagerEnableAccessibleFieldDOMStructure, + PickerManagerError, PickerManagerFieldInternalProps, useControlledValue, useFieldInternalPropsWithDefaults, UseFieldReturnValue, + usePickerPrivateContext, } from '@mui/x-date-pickers/internals'; import { useValidation } from '@mui/x-date-pickers/validation'; import { UseTextFieldBaseForwardedProps, useTextFieldProps } from './useTextFieldProps'; @@ -59,6 +62,7 @@ export function useMultiInputRangeField< >( parameters: UseMultiInputRangeFieldParameters, ): UseMultiInputRangeFieldReturnValue { + type TError = PickerManagerError; const { manager, internalProps, rootProps, startTextFieldProps, endTextFieldProps } = parameters; const internalPropsWithDefaults = useFieldInternalPropsWithDefaults({ @@ -95,11 +99,14 @@ export function useMultiInputRangeField< valueManager: manager.internal_valueManager, }); + const { isPartiallyFilled } = usePickerPrivateContext(); + const validation = useValidation({ props: internalPropsWithDefaults, value, timezone, validator: manager.validator, + isPartiallyFilled, onError: internalPropsWithDefaults.onError, }); @@ -122,7 +129,27 @@ export function useMultiInputRangeField< const rootResponse = useMultiInputRangeFieldRootProps(rootProps); - const startTextFieldResponse = useTextFieldProps({ + const startOnError = React.useCallback( + (error: any, val: any) => { + internalPropsWithDefaults.onError?.( + [error, validation.validationError[1]], + [val, internalPropsWithDefaults.value?.[1] ?? null], + ); + }, + [internalPropsWithDefaults, validation.validationError], + ); + + const endOnError = React.useCallback( + (error: any, val: any) => { + internalPropsWithDefaults.onError?.( + [validation.validationError[0], error], + [internalPropsWithDefaults.value?.[0] ?? null, val], + ); + }, + [internalPropsWithDefaults, validation.validationError], + ); + + const startTextFieldResponse = useTextFieldProps({ valueType: manager.valueType, position: 'start', value, @@ -132,9 +159,11 @@ export function useMultiInputRangeField< forwardedProps: startTextFieldProps, selectedSectionProps: selectedSectionsResponse.start, sharedInternalProps, + isPartiallyFilled, + onError: startOnError, }); - const endTextFieldResponse = useTextFieldProps({ + const endTextFieldResponse = useTextFieldProps({ valueType: manager.valueType, position: 'end', value, @@ -144,6 +173,8 @@ export function useMultiInputRangeField< forwardedProps: endTextFieldProps, selectedSectionProps: selectedSectionsResponse.end, sharedInternalProps, + isPartiallyFilled, + onError: endOnError, }); return { diff --git a/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useTextFieldProps.ts b/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useTextFieldProps.ts index fd48d86e5ca47..d2e9c72bdb5ec 100644 --- a/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useTextFieldProps.ts +++ b/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useTextFieldProps.ts @@ -31,13 +31,13 @@ import type { UseMultiInputRangeFieldTextFieldProps } from './useMultiInputRange export function useTextFieldProps< TManager extends PickerAnyRangeManager, TForwardedProps extends UseTextFieldBaseForwardedProps, + TError, >( - parameters: UseTextFieldPropsParameters, + parameters: UseTextFieldPropsParameters, ): UseMultiInputRangeFieldTextFieldProps< PickerManagerEnableAccessibleFieldDOMStructure, TForwardedProps > { - type TError = PickerManagerError; type TEnableAccessibleFieldDOMStructure = PickerManagerEnableAccessibleFieldDOMStructure; @@ -60,6 +60,8 @@ export function useTextFieldProps< onChange, autoFocus, validation, + isPartiallyFilled, + onError, } = parameters; let useManager: ({ @@ -130,16 +132,19 @@ export function useTextFieldProps< const context: FieldChangeHandlerContext = { ...rawContext, - validationError: validation.getValidationErrorForNewValue(newRange), + validationError: validation.getValidationErrorForNewValue(newRange, isPartiallyFilled), }; onChange(newRange, context); }, ); + const positionIndex = position === 'start' ? 0 : 1; + const rangeValidationError = validation.validationError[positionIndex]; + const allProps = { value: position === 'start' ? value[0] : value[1], - error: position === 'start' ? !!validation.validationError[0] : !!validation.validationError[1], + error: rangeValidationError ? true : undefined, id: `${pickerPrivateContext.labelId}-${position}`, autoFocus: position === 'start' ? autoFocus : undefined, ...forwardedProps, @@ -149,6 +154,7 @@ export function useTextFieldProps< onFocus: handleFocus, onKeyDown: handleKeyDown, onChange: handleChange, + onError, }; const { clearable, onClear, openPickerAriaLabel, ...fieldResponse } = useField({ @@ -194,6 +200,7 @@ export function useTextFieldProps< interface UseTextFieldPropsParameters< TManager extends PickerAnyRangeManager, TForwardedProps extends UseTextFieldBaseForwardedProps, + TError, > { valueType: PickerValueType; value: PickerRangeValue; @@ -204,6 +211,8 @@ interface UseTextFieldPropsParameters< selectedSectionProps: UseMultiInputFieldSelectedSectionsResponseItem; position: RangePosition; validation: UseValidationReturnValue>; + isPartiallyFilled: boolean | [boolean, boolean]; + onError: TError; } export interface UseTextFieldBaseForwardedProps { diff --git a/packages/x-date-pickers-pro/src/internals/utils/valueManagers.ts b/packages/x-date-pickers-pro/src/internals/utils/valueManagers.ts index 621a5852fc5a5..dd547b8936e21 100644 --- a/packages/x-date-pickers-pro/src/internals/utils/valueManagers.ts +++ b/packages/x-date-pickers-pro/src/internals/utils/valueManagers.ts @@ -174,6 +174,18 @@ export const getRangeFieldValueManager = ({ ...dateRangeSections.endDate.map((section) => ({ ...section, value: '' })), ]; }, + getIsPartiallyFilled: (sections) => { + const startSections = sections.filter((section) => (section as FieldRangeSection).dateName === 'start'); + const endSections = sections.filter((section) => (section as FieldRangeSection).dateName === 'end'); + + const startFilled = startSections.filter((section) => section.value !== '').length; + const endFilled = endSections.filter((section) => section.value !== '').length; + + return [ + startFilled > 0 && startFilled < startSections.length, + endFilled > 0 && endFilled < endSections.length, + ] as [boolean, boolean]; + }, }); function getActiveDateIndex(activeSection: FieldRangeSection | null): 0 | 1 { diff --git a/packages/x-date-pickers-pro/src/validation/validateDateRange.ts b/packages/x-date-pickers-pro/src/validation/validateDateRange.ts index b475c73e39a79..552d61f03db4d 100644 --- a/packages/x-date-pickers-pro/src/validation/validateDateRange.ts +++ b/packages/x-date-pickers-pro/src/validation/validateDateRange.ts @@ -29,7 +29,7 @@ export const validateDateRange: Validator< PickerRangeValue, DateRangeValidationError, ValidateDateRangeProps -> = ({ adapter, value, timezone, props }) => { +> = ({ adapter, value, timezone, props, isPartiallyFilled }) => { const [start, end] = value; const { shouldDisableDate, ...otherProps } = props; @@ -43,6 +43,7 @@ export const validateDateRange: Validator< ...otherProps, shouldDisableDate: (day) => !!shouldDisableDate?.(day, 'start'), }, + isPartiallyFilled: isPartiallyFilled?.[0] ?? false, }), validateDate({ adapter, @@ -52,6 +53,7 @@ export const validateDateRange: Validator< ...otherProps, shouldDisableDate: (day) => !!shouldDisableDate?.(day, 'end'), }, + isPartiallyFilled: isPartiallyFilled?.[1] ?? false, }), ]; diff --git a/packages/x-date-pickers-pro/src/validation/validateDateTimeRange.ts b/packages/x-date-pickers-pro/src/validation/validateDateTimeRange.ts index f11c1915cfc42..dad83d52e822d 100644 --- a/packages/x-date-pickers-pro/src/validation/validateDateTimeRange.ts +++ b/packages/x-date-pickers-pro/src/validation/validateDateTimeRange.ts @@ -34,7 +34,7 @@ export const validateDateTimeRange: Validator< PickerRangeValue, DateTimeRangeValidationError, ValidateDateTimeRangeProps -> = ({ adapter, value, timezone, props }) => { +> = ({ adapter, value, timezone, props, isPartiallyFilled }) => { const [start, end] = value; const { shouldDisableDate, ...otherProps } = props; @@ -48,6 +48,7 @@ export const validateDateTimeRange: Validator< ...otherProps, shouldDisableDate: (day) => !!shouldDisableDate?.(day, 'start'), }, + isPartiallyFilled: isPartiallyFilled?.[0] ?? false, }), validateDateTime({ adapter, @@ -57,6 +58,7 @@ export const validateDateTimeRange: Validator< ...otherProps, shouldDisableDate: (day) => !!shouldDisableDate?.(day, 'end'), }, + isPartiallyFilled: isPartiallyFilled?.[1] ?? false, }), ]; diff --git a/packages/x-date-pickers/src/DateField/tests/blurPartialFilling.DateField.test.tsx b/packages/x-date-pickers/src/DateField/tests/blurPartialFilling.DateField.test.tsx index 88d38c4896cd2..6e0067ff1d0c1 100644 --- a/packages/x-date-pickers/src/DateField/tests/blurPartialFilling.DateField.test.tsx +++ b/packages/x-date-pickers/src/DateField/tests/blurPartialFilling.DateField.test.tsx @@ -18,9 +18,6 @@ describeAdapters( 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(); @@ -59,8 +56,6 @@ describeAdapters( // 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); diff --git a/packages/x-date-pickers/src/DateField/tests/invalidStateKeyboard.DateField.test.tsx b/packages/x-date-pickers/src/DateField/tests/invalidStateKeyboard.DateField.test.tsx index 268358cb5cefb..46c4a55c6ac00 100644 --- a/packages/x-date-pickers/src/DateField/tests/invalidStateKeyboard.DateField.test.tsx +++ b/packages/x-date-pickers/src/DateField/tests/invalidStateKeyboard.DateField.test.tsx @@ -26,9 +26,6 @@ describeAdapters( expect(inputRoot).to.have.attribute('aria-invalid', 'true'); await view.selectSectionAsync('day'); - - // Returns to valid after refocusing (incomplete date) - expect(inputRoot).to.have.attribute('aria-invalid', 'false'); await view.user.keyboard('05'); await view.selectSectionAsync('year'); @@ -60,9 +57,6 @@ describeAdapters( expect(input).to.have.attribute('aria-invalid', 'true'); await view.selectSectionAsync('day'); - - // Returns to valid after refocusing (incomplete date) - expect(input).to.have.attribute('aria-invalid', 'false'); await view.user.keyboard('05'); // Move to year and spin using keypress diff --git a/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx b/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx new file mode 100644 index 0000000000000..72f9c2a6e2585 --- /dev/null +++ b/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx @@ -0,0 +1,71 @@ +import { DateField } from '@mui/x-date-pickers/DateField'; +import { describeAdapters, getFieldInputRoot, getTextbox } from 'test/utils/pickers'; +import { fireEvent } from '@mui/internal-test-utils'; +import { spy } from 'sinon'; + +describeAdapters( + 'DateField - partiallyFilledDate validation', + DateField, + ({ adapter, renderWithProps }) => { + it('should call onError with partiallyFilledDate when date is partially filled', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, + }); + + await view.selectSectionAsync('month'); + await view.user.keyboard('01'); + + expect(onError.callCount).to.equal(1); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + }); + + it('should call onError with null when partially filled field is fully cleared', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, + }); + + await view.selectSectionAsync('month'); + await view.user.keyboard('01'); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + + await view.selectSectionAsync('month'); + await view.user.keyboard('{Control>}a{/Control}'); + await view.user.keyboard('{Delete}'); + + expect(onError.lastCall.args[0]).to.equal(null); + }); + it('should return invalidDate error when all sections filled but date is invalid', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, + }); + + await view.selectSectionAsync('month'); + await view.user.keyboard('02'); + await view.selectSectionAsync('day'); + await view.user.keyboard('30'); + + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + + await view.selectSectionAsync('year'); + await view.user.keyboard('2024'); + + expect(onError.lastCall.args[0]).not.to.equal('partiallyFilledDate'); + expect(onError.lastCall.args[0]).to.equal('invalidDate'); + }); + + it('should show field as invalid when partially filled', async () => { + const view = renderWithProps({ enableAccessibleFieldDOMStructure: true }); + + await view.selectSectionAsync('month'); + await view.user.keyboard('01'); + + expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'true'); + }); + }, +); diff --git a/packages/x-date-pickers/src/DatePicker/tests/partialFillValidation.DatePicker.test.tsx b/packages/x-date-pickers/src/DatePicker/tests/partialFillValidation.DatePicker.test.tsx new file mode 100644 index 0000000000000..b4d512dd57f31 --- /dev/null +++ b/packages/x-date-pickers/src/DatePicker/tests/partialFillValidation.DatePicker.test.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { spy } from 'sinon'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { screen } from '@mui/internal-test-utils'; +import { createPickerRenderer, getFieldInputRoot } from 'test/utils/pickers'; + +describe(' - partiallyFilledDate validation', () => { + const { render } = createPickerRenderer(); + + it('should call onError with partiallyFilledDate when date is partially filled', async () => { + const onError = spy(); + const { user } = render(); + + const month = screen.getByRole('spinbutton', { name: 'Month' }); + await user.click(month); + await user.keyboard('01'); + + expect(onError.callCount).to.equal(1); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + }); + + it('should call onError with null when partially filled field is fully cleared', async () => { + const onError = spy(); + const { user } = render(); + + const month = screen.getByRole('spinbutton', { name: 'Month' }); + await user.click(month); + await user.keyboard('01'); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + + await user.click(month); + await user.keyboard('{Control>}a{/Control}'); + await user.keyboard('{Delete}'); + + expect(onError.lastCall.args[0]).to.equal(null); + }); + + it('should return invalidDate error when all sections filled but date is invalid', async () => { + const onError = spy(); + const { user } = render(); + + const month = screen.getByRole('spinbutton', { name: 'Month' }); + await user.click(month); + await user.keyboard('02'); + + const day = screen.getByRole('spinbutton', { name: 'Day' }); + await user.click(day); + await user.keyboard('30'); + + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + + const year = screen.getByRole('spinbutton', { name: 'Year' }); + await user.click(year); + await user.keyboard('2024'); + + expect(onError.lastCall.args[0]).not.to.equal('partiallyFilledDate'); + expect(onError.lastCall.args[0]).to.equal('invalidDate'); + }); + + it('should show field as invalid when partially filled', async () => { + const { user } = render(); + + const month = screen.getByRole('spinbutton', { name: 'Month' }); + await user.click(month); + await user.keyboard('01'); + + expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'true'); + }); +}); diff --git a/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx b/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx new file mode 100644 index 0000000000000..c210777493e3d --- /dev/null +++ b/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx @@ -0,0 +1,77 @@ +import { DateTimeField } from '@mui/x-date-pickers/DateTimeField'; +import { describeAdapters, getFieldInputRoot, getTextbox } from 'test/utils/pickers'; +import { fireEvent } from '@mui/internal-test-utils'; +import { spy } from 'sinon'; + +describeAdapters( + 'DateTimeField - partiallyFilledDate validation', + DateTimeField, + ({ adapter, renderWithProps }) => { + it('should call onError with partiallyFilledDate when date is partially filled', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, + }); + + await view.selectSectionAsync('month'); + await view.user.keyboard('01'); + + expect(onError.callCount).to.equal(1); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + }); + + it('should call onError with null when partially filled field is fully cleared', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, + }); + + await view.selectSectionAsync('month'); + await view.user.keyboard('01'); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + + await view.selectSectionAsync('month'); + await view.user.keyboard('{Control>}a{/Control}'); + await view.user.keyboard('{Delete}'); + + expect(onError.lastCall.args[0]).to.equal(null); + }); + it('should return invalidDate error when all sections filled but date is invalid', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, + }); + + await view.selectSectionAsync('month'); + await view.user.keyboard('02'); + await view.selectSectionAsync('day'); + await view.user.keyboard('30'); + await view.selectSectionAsync('year'); + await view.user.keyboard('2024'); + await view.selectSectionAsync('hours'); + await view.user.keyboard('10'); + await view.selectSectionAsync('minutes'); + await view.user.keyboard('30'); + + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + + await view.selectSectionAsync('meridiem'); + await view.user.keyboard('AM'); + + expect(onError.lastCall.args[0]).not.to.equal('partiallyFilledDate'); + expect(onError.lastCall.args[0]).to.equal('invalidDate'); + }); + + it('should show field as invalid when partially filled', async () => { + const view = renderWithProps({ enableAccessibleFieldDOMStructure: true }); + + await view.selectSectionAsync('month'); + await view.user.keyboard('01'); + + expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'true'); + }); + }, +); diff --git a/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx b/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx new file mode 100644 index 0000000000000..414edbbb0fbf2 --- /dev/null +++ b/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx @@ -0,0 +1,71 @@ +import { TimeField } from '@mui/x-date-pickers/TimeField'; +import { describeAdapters, getFieldInputRoot, getTextbox } from 'test/utils/pickers'; +import { fireEvent } from '@mui/internal-test-utils'; +import { spy } from 'sinon'; + +describeAdapters( + 'TimeField - partiallyFilledDate validation', + TimeField, + ({ adapter, renderWithProps }) => { + it('should call onError with partiallyFilledDate when date is partially filled', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, + }); + + await view.selectSectionAsync('hours'); + await view.user.keyboard('10'); + + expect(onError.callCount).to.equal(1); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + }); + + it('should call onError with null when partially filled field is fully cleared', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, + }); + + await view.selectSectionAsync('hours'); + await view.user.keyboard('10'); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + + await view.selectSectionAsync('hours'); + await view.user.keyboard('{Control>}a{/Control}'); + await view.user.keyboard('{Delete}'); + + expect(onError.lastCall.args[0]).to.equal(null); + }); + it('should return null error when all sections filled', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, + }); + + await view.selectSectionAsync('hours'); + await view.user.keyboard('10'); + await view.selectSectionAsync('minutes'); + await view.user.keyboard('10'); + + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + + await view.selectSectionAsync('meridiem'); + await view.user.keyboard('AM'); + + expect(onError.lastCall.args[0]).not.to.equal('partiallyFilledDate'); + expect(onError.lastCall.args[0]).to.equal(null); + }); + + it('should show field as invalid when partially filled', async () => { + const view = renderWithProps({ enableAccessibleFieldDOMStructure: true }); + + await view.selectSectionAsync('hours'); + await view.user.keyboard('10'); + + expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'true'); + }); + }, +); diff --git a/packages/x-date-pickers/src/internals/components/PickerProvider.tsx b/packages/x-date-pickers/src/internals/components/PickerProvider.tsx index a033c10a98f91..eea05d6f7b5a3 100644 --- a/packages/x-date-pickers/src/internals/components/PickerProvider.tsx +++ b/packages/x-date-pickers/src/internals/components/PickerProvider.tsx @@ -45,6 +45,8 @@ export const PickerPrivateContext = React.createContext {}, }); /** @@ -383,4 +385,10 @@ export interface PickerPrivateContextValue { * The function to call when the Popper is closing animation is finished. */ onPopperExited: (() => void) | undefined; + /** + * Whether the field is partially filled. + * Set by the field, read by the picker to pass into validation. + */ + isPartiallyFilled: boolean | [boolean, boolean]; + setIsPartiallyFilled: (fieldId: string, value: boolean | [boolean, boolean]) => void; } diff --git a/packages/x-date-pickers/src/internals/hooks/useField/useField.types.ts b/packages/x-date-pickers/src/internals/hooks/useField/useField.types.ts index 8fb63e6f1e681..33d1a9851fa8e 100644 --- a/packages/x-date-pickers/src/internals/hooks/useField/useField.types.ts +++ b/packages/x-date-pickers/src/internals/hooks/useField/useField.types.ts @@ -325,6 +325,8 @@ export interface FieldValueManager { sections: InferFieldSection[], section: InferFieldSection, ) => InferFieldSection[]; + + getIsPartiallyFilled: (sections: InferFieldSection[]) => boolean | [boolean, boolean]; } export interface UseFieldState { diff --git a/packages/x-date-pickers/src/internals/hooks/useField/useFieldState.ts b/packages/x-date-pickers/src/internals/hooks/useField/useFieldState.ts index 5dcfd1bd6870e..c5b4274df921b 100644 --- a/packages/x-date-pickers/src/internals/hooks/useField/useFieldState.ts +++ b/packages/x-date-pickers/src/internals/hooks/useField/useFieldState.ts @@ -4,6 +4,7 @@ import useControlled from '@mui/utils/useControlled'; import useTimeout from '@mui/utils/useTimeout'; import useEventCallback from '@mui/utils/useEventCallback'; import { useRtl } from '@mui/system/RtlProvider'; +import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; import { usePickerAdapter, usePickerTranslations } from '../../../hooks'; import { UseFieldInternalProps, @@ -39,6 +40,7 @@ import { getSectionTypeGranularity, } from '../../utils/getDefaultReferenceDate'; import { PickerValidValue } from '../../models'; +import { usePickerPrivateContext } from '../../../internals/hooks/usePickerPrivateContext'; const QUERY_LIFE_DURATION_MS = 5000; @@ -99,21 +101,8 @@ export const useFieldState = < valueRef.current = value; }, [value]); - const { hasValidationError } = useValidation({ - props: internalPropsWithDefaults, - validator, - timezone, - value, - onError: internalPropsWithDefaults.onError, - }); - const localizedDigits = React.useMemo(() => getLocalizedDigits(adapter), [adapter]); - const sectionsValueBoundaries = React.useMemo( - () => getSectionsBoundaries(adapter, localizedDigits, timezone), - [adapter, localizedDigits, timezone], - ); - const getSectionsFromValue = React.useCallback( (valueToAnalyze: TValue) => fieldValueManager.getSectionsFromValue(valueToAnalyze, (date) => @@ -170,6 +159,25 @@ export const useFieldState = < }; }); + const isPartiallyFilled = React.useMemo( + () => fieldValueManager.getIsPartiallyFilled(state.sections), + [fieldValueManager, state.sections], + ); + + const { hasValidationError } = useValidation({ + props: internalPropsWithDefaults, + validator, + timezone, + value, + isPartiallyFilled, + onError: internalPropsWithDefaults.onError, + }); + + const sectionsValueBoundaries = React.useMemo( + () => getSectionsBoundaries(adapter, localizedDigits, timezone), + [adapter, localizedDigits, timezone], + ); + const [selectedSections, innerSetSelectedSections] = useControlled({ controlled: selectedSectionsProp, default: null, @@ -199,6 +207,14 @@ export const useFieldState = < [state.sections], ); + const fieldId = React.useId(); + const { setIsPartiallyFilled } = usePickerPrivateContext(); + + useEnhancedEffect(() => { + setIsPartiallyFilled(fieldId, isPartiallyFilled); + return () => setIsPartiallyFilled(fieldId, false); + }, [fieldId, isPartiallyFilled, setIsPartiallyFilled]); + // 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(() => { @@ -225,6 +241,7 @@ export const useFieldState = < value: newValue, timezone, props: internalPropsWithDefaults, + isPartiallyFilled, }), }; diff --git a/packages/x-date-pickers/src/internals/hooks/usePicker/hooks/useValueAndOpenStates.ts b/packages/x-date-pickers/src/internals/hooks/usePicker/hooks/useValueAndOpenStates.ts index 06ae5a8fa250e..f57f5713d4246 100644 --- a/packages/x-date-pickers/src/internals/hooks/usePicker/hooks/useValueAndOpenStates.ts +++ b/packages/x-date-pickers/src/internals/hooks/usePicker/hooks/useValueAndOpenStates.ts @@ -17,7 +17,7 @@ export function useValueAndOpenStates< >(parameters: UsePickerDateStateParameters) { type TError = InferError; - const { props, valueManager, validator } = parameters; + const { props, valueManager, validator, isPartiallyFilled } = parameters; const { value: valueProp, defaultValue: defaultValueProp, @@ -101,6 +101,7 @@ export function useValueAndOpenStates< validator, timezone, value, + isPartiallyFilled, onError: props.onError, }); @@ -165,7 +166,9 @@ export function useValueAndOpenStates< } cachedContext = { validationError: - validationError == null ? getValidationErrorForNewValue(newValue) : validationError, + validationError == null + ? getValidationErrorForNewValue(newValue, isPartiallyFilled) + : validationError, source: inferredSource, }; @@ -253,4 +256,5 @@ interface UsePickerDateStateParameters< props: TExternalProps; valueManager: PickerValueManager>; validator: Validator, TExternalProps>; + isPartiallyFilled: boolean | [boolean, boolean]; } diff --git a/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.tsx b/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.tsx index 1f20c865a83e7..608b2d091ff13 100644 --- a/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.tsx +++ b/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.tsx @@ -100,11 +100,44 @@ export const usePicker = < const rootRefObject = React.useRef(null); const rootRef = useForkRef(ref, rootRefObject); + const partiallyFilledMapRef = React.useRef(new Map()); + const [isPartiallyFilled, setIsPartiallyFilledState] = React.useState< + boolean | [boolean, boolean] + >(false); + + const setIsPartiallyFilled = useEventCallback((fieldId: string, isPartial: boolean | [boolean, boolean]) => { + // partiallyFilledMapRef.current.set(fieldId, isPartial); + // + // const values = Array.from(partiallyFilledMapRef.current.values()); + // + // if (values.length === 2) { + // setIsPartiallyFilledState([values[0], values[1]]); + // } else { + // setIsPartiallyFilledState(values[0] ?? false); + // } + + if (Array.isArray(isPartial)) { + setIsPartiallyFilledState(isPartial); + return; + } + + // Для одиночных полей (DateField) используем старую логику с Map + partiallyFilledMapRef.current.set(fieldId, isPartial); + const values = Array.from(partiallyFilledMapRef.current.values()); + + if (values.length === 2) { + setIsPartiallyFilledState([values[0], values[1]]); + } else { + setIsPartiallyFilledState(values[0] ?? false); + } + }); + const { timezone, state, setOpen, setValue, setValueFromView, value, viewValue } = useValueAndOpenStates({ props, valueManager, validator, + isPartiallyFilled, }); const { @@ -361,6 +394,8 @@ export const usePicker = < viewContainerRole, defaultActionBarActions, onPopperExited, + isPartiallyFilled, + setIsPartiallyFilled, }), [ dismissViews, @@ -372,6 +407,8 @@ export const usePicker = < viewContainerRole, defaultActionBarActions, onPopperExited, + isPartiallyFilled, + setIsPartiallyFilled, ], ); @@ -398,6 +435,7 @@ export const usePicker = < value: testedValue, timezone, props, + isPartiallyFilled, }); return !valueManager.hasError(error); diff --git a/packages/x-date-pickers/src/internals/utils/valueManagers.ts b/packages/x-date-pickers/src/internals/utils/valueManagers.ts index eb6bab06bbeda..0cd513429cf62 100644 --- a/packages/x-date-pickers/src/internals/utils/valueManagers.ts +++ b/packages/x-date-pickers/src/internals/utils/valueManagers.ts @@ -49,4 +49,8 @@ export const singleItemFieldValueManager: FieldValueManager = { getDateSectionsFromValue: (sections) => sections, updateDateInValue: (value, activeSection, activeDate) => activeDate, clearDateSections: (sections) => sections.map((section) => ({ ...section, value: '' })), + getIsPartiallyFilled: (sections) => { + const filled = sections.filter((section) => section.value !== '').length; + return filled > 0 && filled < sections.length; + }, }; diff --git a/packages/x-date-pickers/src/models/validation.ts b/packages/x-date-pickers/src/models/validation.ts index 04833a901699c..9f49c8158ed98 100644 --- a/packages/x-date-pickers/src/models/validation.ts +++ b/packages/x-date-pickers/src/models/validation.ts @@ -3,7 +3,12 @@ import type { PickerValidValue } from '../internals/models'; /** * Validation error types applicable to both date and time validation */ -type CommonDateTimeValidationError = 'invalidDate' | 'disableFuture' | 'disablePast' | null; +type CommonDateTimeValidationError = + | 'invalidDate' + | 'partiallyFilledDate' + | 'disableFuture' + | 'disablePast' + | null; export type DateValidationError = | CommonDateTimeValidationError diff --git a/packages/x-date-pickers/src/validation/useValidation.ts b/packages/x-date-pickers/src/validation/useValidation.ts index 261640a7f6943..a371d3f55fbb6 100644 --- a/packages/x-date-pickers/src/validation/useValidation.ts +++ b/packages/x-date-pickers/src/validation/useValidation.ts @@ -1,10 +1,10 @@ 'use client'; import * as React from 'react'; import useEventCallback from '@mui/utils/useEventCallback'; -import { MuiPickersAdapter, OnErrorProps, PickersTimezone } from '../models'; -import type { PickerValueManager } from '../internals/models'; -import { PickerValidValue } from '../internals/models'; -import { usePickerAdapter } from '../hooks'; +import {MuiPickersAdapter, OnErrorProps, PickersTimezone} from '../models'; +import type {PickerValueManager} from '../internals/models'; +import {PickerValidValue} from '../internals/models'; +import {usePickerAdapter} from '../hooks'; export type Validator = { (params: { @@ -12,6 +12,7 @@ export type Validator value: TValue; timezone: PickersTimezone; props: TValidationProps; + isPartiallyFilled: boolean | [boolean, boolean]; }): TError; valueManager: PickerValueManager; }; @@ -41,6 +42,7 @@ interface UseValidationOptions< * For example, the `validateTime` function supports `minTime`, `maxTime`, etc. */ props: TValidationProps; + isPartiallyFilled: boolean | [boolean, boolean]; } export interface UseValidationReturnValue { @@ -59,9 +61,13 @@ export interface UseValidationReturnValue TError; + getValidationErrorForNewValue: ( + newValue: TValue, + newIsPartiallyFilled: boolean | [boolean, boolean], + ) => TError; } /** @@ -74,18 +80,19 @@ export interface UseValidationReturnValue} options.validator The validator function to use. * @param {TValidationProps} options.props The validation props, they differ depending on the component. * @param {(error: TError, value: TValue) => void} options.onError Callback fired when the error associated with the current value changes. + * @param {boolean | [boolean, boolean]} options.isPartiallyFilled Whether the value is partially filled. */ export function useValidation( options: UseValidationOptions, ): UseValidationReturnValue { - const { props, validator, value, timezone, onError } = options; + const { props, validator, value, timezone, onError, isPartiallyFilled } = options; const adapter = usePickerAdapter(); const previousValidationErrorRef = React.useRef( validator.valueManager.defaultErrorState, ); - const validationError = validator({ adapter, value, timezone, props }); + const validationError = validator({ adapter, value, timezone, props, isPartiallyFilled }); const hasValidationError = validator.valueManager.hasError(validationError); React.useEffect(() => { @@ -99,9 +106,17 @@ export function useValidation { - return validator({ adapter, value: newValue, timezone, props }); - }); + const getValidationErrorForNewValue = useEventCallback( + (newValue: TValue, newIsPartiallyFilled: boolean | [boolean, boolean]) => { + return validator({ + adapter, + value: newValue, + timezone, + props, + isPartiallyFilled: newIsPartiallyFilled, + }); + }, + ); return { validationError, hasValidationError, getValidationErrorForNewValue }; } diff --git a/packages/x-date-pickers/src/validation/validateDate.ts b/packages/x-date-pickers/src/validation/validateDate.ts index 724adf0ee459d..b7fb1cec13cd4 100644 --- a/packages/x-date-pickers/src/validation/validateDate.ts +++ b/packages/x-date-pickers/src/validation/validateDate.ts @@ -34,7 +34,12 @@ export const validateDate: Validator { + if (isPartiallyFilled) { + return 'partiallyFilledDate'; + } + if (value === null) { return null; } diff --git a/packages/x-date-pickers/src/validation/validateDateTime.ts b/packages/x-date-pickers/src/validation/validateDateTime.ts index 7c1985c44f00d..f499d34fee526 100644 --- a/packages/x-date-pickers/src/validation/validateDateTime.ts +++ b/packages/x-date-pickers/src/validation/validateDateTime.ts @@ -38,12 +38,13 @@ export const validateDateTime: Validator< PickerValue, DateTimeValidationError, ValidateDateTimeProps -> = ({ adapter, value, timezone, props }) => { +> = ({ adapter, value, timezone, props, isPartiallyFilled }) => { const dateValidationResult = validateDate({ adapter, value, timezone, props, + isPartiallyFilled, }); if (dateValidationResult !== null) { @@ -55,6 +56,7 @@ export const validateDateTime: Validator< value, timezone, props, + isPartiallyFilled, }); }; diff --git a/packages/x-date-pickers/src/validation/validateTime.ts b/packages/x-date-pickers/src/validation/validateTime.ts index d38de70ebaea5..0aa49971ae32f 100644 --- a/packages/x-date-pickers/src/validation/validateTime.ts +++ b/packages/x-date-pickers/src/validation/validateTime.ts @@ -29,7 +29,12 @@ export const validateTime: Validator { + if (isPartiallyFilled) { + return 'partiallyFilledDate'; + } + if (value === null) { return null; } From 8388592bc6b02a163f9075eb62014dafcd2761e9 Mon Sep 17 00:00:00 2001 From: Anastasia Mastyaeva Date: Mon, 6 Apr 2026 19:39:41 +0300 Subject: [PATCH 2/4] fix(pickers): fix eslint --- .../DateField/tests/partialFillValidation.DateField.test.tsx | 5 ++--- .../tests/partialFillValidation.DateTimeField.test.tsx | 5 ++--- .../TimeField/tests/partialFillValidation.TimeField.test.tsx | 5 ++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx b/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx index 72f9c2a6e2585..2c17c7ef455b2 100644 --- a/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx +++ b/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx @@ -1,12 +1,11 @@ import { DateField } from '@mui/x-date-pickers/DateField'; -import { describeAdapters, getFieldInputRoot, getTextbox } from 'test/utils/pickers'; -import { fireEvent } from '@mui/internal-test-utils'; +import { describeAdapters, getFieldInputRoot } from 'test/utils/pickers'; import { spy } from 'sinon'; describeAdapters( 'DateField - partiallyFilledDate validation', DateField, - ({ adapter, renderWithProps }) => { + ({renderWithProps }) => { it('should call onError with partiallyFilledDate when date is partially filled', async () => { const onError = spy(); const view = renderWithProps({ diff --git a/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx b/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx index c210777493e3d..e9e4a8c0d4eff 100644 --- a/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx +++ b/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx @@ -1,12 +1,11 @@ import { DateTimeField } from '@mui/x-date-pickers/DateTimeField'; -import { describeAdapters, getFieldInputRoot, getTextbox } from 'test/utils/pickers'; -import { fireEvent } from '@mui/internal-test-utils'; +import { describeAdapters, getFieldInputRoot } from 'test/utils/pickers'; import { spy } from 'sinon'; describeAdapters( 'DateTimeField - partiallyFilledDate validation', DateTimeField, - ({ adapter, renderWithProps }) => { + ({renderWithProps }) => { it('should call onError with partiallyFilledDate when date is partially filled', async () => { const onError = spy(); const view = renderWithProps({ diff --git a/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx b/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx index 414edbbb0fbf2..04190a997354b 100644 --- a/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx +++ b/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx @@ -1,12 +1,11 @@ import { TimeField } from '@mui/x-date-pickers/TimeField'; -import { describeAdapters, getFieldInputRoot, getTextbox } from 'test/utils/pickers'; -import { fireEvent } from '@mui/internal-test-utils'; +import { describeAdapters, getFieldInputRoot } from 'test/utils/pickers'; import { spy } from 'sinon'; describeAdapters( 'TimeField - partiallyFilledDate validation', TimeField, - ({ adapter, renderWithProps }) => { + ({renderWithProps }) => { it('should call onError with partiallyFilledDate when date is partially filled', async () => { const onError = spy(); const view = renderWithProps({ From 2ff824f2d285a6652f6875aa935500e87987b6e0 Mon Sep 17 00:00:00 2001 From: Anastasia Mastyaeva Date: Wed, 8 Apr 2026 11:34:55 +0300 Subject: [PATCH 3/4] fix(pickers): fix eslint, ts errors --- ...scribeConformance.DateRangePicker.test.tsx | 2 +- .../src/internals/utils/valueManagers.ts | 8 +- .../src/validation/validateDateRange.ts | 8 +- .../src/validation/validateDateTimeRange.ts | 8 +- .../src/validation/validateTimeRange.ts | 8 +- .../partialFillValidation.DateField.test.tsx | 100 +++++++++--------- ...rtialFillValidation.DateTimeField.test.tsx | 2 +- .../partialFillValidation.TimeField.test.tsx | 100 +++++++++--------- .../internals/hooks/usePicker/usePicker.tsx | 43 +++----- .../src/validation/useValidation.ts | 20 ++-- 10 files changed, 150 insertions(+), 149 deletions(-) diff --git a/packages/x-date-pickers-pro/src/DateRangePicker/tests/describeConformance.DateRangePicker.test.tsx b/packages/x-date-pickers-pro/src/DateRangePicker/tests/describeConformance.DateRangePicker.test.tsx index 6430ac2b77fa8..41f4126e4f9a9 100644 --- a/packages/x-date-pickers-pro/src/DateRangePicker/tests/describeConformance.DateRangePicker.test.tsx +++ b/packages/x-date-pickers-pro/src/DateRangePicker/tests/describeConformance.DateRangePicker.test.tsx @@ -1,6 +1,6 @@ import { DateRangePicker } from '@mui/x-date-pickers-pro/DateRangePicker'; import { createPickerRenderer } from 'test/utils/pickers'; -import { describeConformance } from 'test/utils/describeConformance.ts'; +import { describeConformance } from 'test/utils/describeConformance'; describe(' - Describe Conformance', () => { const { render } = createPickerRenderer(); diff --git a/packages/x-date-pickers-pro/src/internals/utils/valueManagers.ts b/packages/x-date-pickers-pro/src/internals/utils/valueManagers.ts index dd547b8936e21..17fa1df501c95 100644 --- a/packages/x-date-pickers-pro/src/internals/utils/valueManagers.ts +++ b/packages/x-date-pickers-pro/src/internals/utils/valueManagers.ts @@ -175,8 +175,12 @@ export const getRangeFieldValueManager = ({ ]; }, getIsPartiallyFilled: (sections) => { - const startSections = sections.filter((section) => (section as FieldRangeSection).dateName === 'start'); - const endSections = sections.filter((section) => (section as FieldRangeSection).dateName === 'end'); + const startSections = sections.filter( + (section) => (section as FieldRangeSection).dateName === 'start', + ); + const endSections = sections.filter( + (section) => (section as FieldRangeSection).dateName === 'end', + ); const startFilled = startSections.filter((section) => section.value !== '').length; const endFilled = endSections.filter((section) => section.value !== '').length; diff --git a/packages/x-date-pickers-pro/src/validation/validateDateRange.ts b/packages/x-date-pickers-pro/src/validation/validateDateRange.ts index 552d61f03db4d..5adc3cd5c566b 100644 --- a/packages/x-date-pickers-pro/src/validation/validateDateRange.ts +++ b/packages/x-date-pickers-pro/src/validation/validateDateRange.ts @@ -34,6 +34,10 @@ export const validateDateRange: Validator< const { shouldDisableDate, ...otherProps } = props; + const isPartiallyFilledArray: [boolean, boolean] = Array.isArray(isPartiallyFilled) + ? isPartiallyFilled + : [isPartiallyFilled ?? false, isPartiallyFilled ?? false]; + const dateValidations: DateRangeValidationError = [ validateDate({ adapter, @@ -43,7 +47,7 @@ export const validateDateRange: Validator< ...otherProps, shouldDisableDate: (day) => !!shouldDisableDate?.(day, 'start'), }, - isPartiallyFilled: isPartiallyFilled?.[0] ?? false, + isPartiallyFilled: isPartiallyFilledArray[0], }), validateDate({ adapter, @@ -53,7 +57,7 @@ export const validateDateRange: Validator< ...otherProps, shouldDisableDate: (day) => !!shouldDisableDate?.(day, 'end'), }, - isPartiallyFilled: isPartiallyFilled?.[1] ?? false, + isPartiallyFilled: isPartiallyFilledArray[1], }), ]; diff --git a/packages/x-date-pickers-pro/src/validation/validateDateTimeRange.ts b/packages/x-date-pickers-pro/src/validation/validateDateTimeRange.ts index dad83d52e822d..3f95bf01d36d9 100644 --- a/packages/x-date-pickers-pro/src/validation/validateDateTimeRange.ts +++ b/packages/x-date-pickers-pro/src/validation/validateDateTimeRange.ts @@ -39,6 +39,10 @@ export const validateDateTimeRange: Validator< const { shouldDisableDate, ...otherProps } = props; + const isPartiallyFilledArray: [boolean, boolean] = Array.isArray(isPartiallyFilled) + ? isPartiallyFilled + : [isPartiallyFilled ?? false, isPartiallyFilled ?? false]; + const dateTimeValidations: DateTimeRangeValidationError = [ validateDateTime({ adapter, @@ -48,7 +52,7 @@ export const validateDateTimeRange: Validator< ...otherProps, shouldDisableDate: (day) => !!shouldDisableDate?.(day, 'start'), }, - isPartiallyFilled: isPartiallyFilled?.[0] ?? false, + isPartiallyFilled: isPartiallyFilledArray[0], }), validateDateTime({ adapter, @@ -58,7 +62,7 @@ export const validateDateTimeRange: Validator< ...otherProps, shouldDisableDate: (day) => !!shouldDisableDate?.(day, 'end'), }, - isPartiallyFilled: isPartiallyFilled?.[1] ?? false, + isPartiallyFilled: isPartiallyFilledArray[1], }), ]; diff --git a/packages/x-date-pickers-pro/src/validation/validateTimeRange.ts b/packages/x-date-pickers-pro/src/validation/validateTimeRange.ts index 1682b2fbdefe9..61c8ac08ad8fe 100644 --- a/packages/x-date-pickers-pro/src/validation/validateTimeRange.ts +++ b/packages/x-date-pickers-pro/src/validation/validateTimeRange.ts @@ -32,21 +32,27 @@ export const validateTimeRange: Validator< PickerRangeValue, TimeRangeValidationError, ValidateTimeRangeProps -> = ({ adapter, value, timezone, props }) => { +> = ({ adapter, value, timezone, props, isPartiallyFilled }) => { const [start, end] = value; + const isPartiallyFilledArray: [boolean, boolean] = Array.isArray(isPartiallyFilled) + ? isPartiallyFilled + : [isPartiallyFilled ?? false, isPartiallyFilled ?? false]; + const dateTimeValidations: TimeRangeValidationError = [ validateTime({ adapter, value: start, timezone, props, + isPartiallyFilled: isPartiallyFilledArray[0], }), validateTime({ adapter, value: end, timezone, props, + isPartiallyFilled: isPartiallyFilledArray[1], }), ]; diff --git a/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx b/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx index 2c17c7ef455b2..164c05f9903a4 100644 --- a/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx +++ b/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx @@ -2,69 +2,65 @@ import { DateField } from '@mui/x-date-pickers/DateField'; import { describeAdapters, getFieldInputRoot } from 'test/utils/pickers'; import { spy } from 'sinon'; -describeAdapters( - 'DateField - partiallyFilledDate validation', - DateField, - ({renderWithProps }) => { - it('should call onError with partiallyFilledDate when date is partially filled', async () => { - const onError = spy(); - const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, - onError, - }); +describeAdapters('DateField - partiallyFilledDate validation', DateField, ({ renderWithProps }) => { + it('should call onError with partiallyFilledDate when date is partially filled', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, + }); - await view.selectSectionAsync('month'); - await view.user.keyboard('01'); + await view.selectSectionAsync('month'); + await view.user.keyboard('01'); - expect(onError.callCount).to.equal(1); - expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); - }); + expect(onError.callCount).to.equal(1); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + }); - it('should call onError with null when partially filled field is fully cleared', async () => { - const onError = spy(); - const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, - onError, - }); + it('should call onError with null when partially filled field is fully cleared', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, + }); - await view.selectSectionAsync('month'); - await view.user.keyboard('01'); - expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + await view.selectSectionAsync('month'); + await view.user.keyboard('01'); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); - await view.selectSectionAsync('month'); - await view.user.keyboard('{Control>}a{/Control}'); - await view.user.keyboard('{Delete}'); + await view.selectSectionAsync('month'); + await view.user.keyboard('{Control>}a{/Control}'); + await view.user.keyboard('{Delete}'); - expect(onError.lastCall.args[0]).to.equal(null); + expect(onError.lastCall.args[0]).to.equal(null); + }); + it('should return invalidDate error when all sections filled but date is invalid', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, }); - it('should return invalidDate error when all sections filled but date is invalid', async () => { - const onError = spy(); - const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, - onError, - }); - await view.selectSectionAsync('month'); - await view.user.keyboard('02'); - await view.selectSectionAsync('day'); - await view.user.keyboard('30'); + await view.selectSectionAsync('month'); + await view.user.keyboard('02'); + await view.selectSectionAsync('day'); + await view.user.keyboard('30'); - expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); - await view.selectSectionAsync('year'); - await view.user.keyboard('2024'); + await view.selectSectionAsync('year'); + await view.user.keyboard('2024'); - expect(onError.lastCall.args[0]).not.to.equal('partiallyFilledDate'); - expect(onError.lastCall.args[0]).to.equal('invalidDate'); - }); + expect(onError.lastCall.args[0]).not.to.equal('partiallyFilledDate'); + expect(onError.lastCall.args[0]).to.equal('invalidDate'); + }); - it('should show field as invalid when partially filled', async () => { - const view = renderWithProps({ enableAccessibleFieldDOMStructure: true }); + it('should show field as invalid when partially filled', async () => { + const view = renderWithProps({ enableAccessibleFieldDOMStructure: true }); - await view.selectSectionAsync('month'); - await view.user.keyboard('01'); + await view.selectSectionAsync('month'); + await view.user.keyboard('01'); - expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'true'); - }); - }, -); + expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'true'); + }); +}); diff --git a/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx b/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx index e9e4a8c0d4eff..f9681af66f0a0 100644 --- a/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx +++ b/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx @@ -5,7 +5,7 @@ import { spy } from 'sinon'; describeAdapters( 'DateTimeField - partiallyFilledDate validation', DateTimeField, - ({renderWithProps }) => { + ({ renderWithProps }) => { it('should call onError with partiallyFilledDate when date is partially filled', async () => { const onError = spy(); const view = renderWithProps({ diff --git a/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx b/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx index 04190a997354b..b380d084c9c0f 100644 --- a/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx +++ b/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx @@ -2,69 +2,65 @@ import { TimeField } from '@mui/x-date-pickers/TimeField'; import { describeAdapters, getFieldInputRoot } from 'test/utils/pickers'; import { spy } from 'sinon'; -describeAdapters( - 'TimeField - partiallyFilledDate validation', - TimeField, - ({renderWithProps }) => { - it('should call onError with partiallyFilledDate when date is partially filled', async () => { - const onError = spy(); - const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, - onError, - }); +describeAdapters('TimeField - partiallyFilledDate validation', TimeField, ({ renderWithProps }) => { + it('should call onError with partiallyFilledDate when date is partially filled', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, + }); - await view.selectSectionAsync('hours'); - await view.user.keyboard('10'); + await view.selectSectionAsync('hours'); + await view.user.keyboard('10'); - expect(onError.callCount).to.equal(1); - expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); - }); + expect(onError.callCount).to.equal(1); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + }); - it('should call onError with null when partially filled field is fully cleared', async () => { - const onError = spy(); - const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, - onError, - }); + it('should call onError with null when partially filled field is fully cleared', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, + }); - await view.selectSectionAsync('hours'); - await view.user.keyboard('10'); - expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + await view.selectSectionAsync('hours'); + await view.user.keyboard('10'); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); - await view.selectSectionAsync('hours'); - await view.user.keyboard('{Control>}a{/Control}'); - await view.user.keyboard('{Delete}'); + await view.selectSectionAsync('hours'); + await view.user.keyboard('{Control>}a{/Control}'); + await view.user.keyboard('{Delete}'); - expect(onError.lastCall.args[0]).to.equal(null); + expect(onError.lastCall.args[0]).to.equal(null); + }); + it('should return null error when all sections filled', async () => { + const onError = spy(); + const view = renderWithProps({ + enableAccessibleFieldDOMStructure: true, + onError, }); - it('should return null error when all sections filled', async () => { - const onError = spy(); - const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, - onError, - }); - await view.selectSectionAsync('hours'); - await view.user.keyboard('10'); - await view.selectSectionAsync('minutes'); - await view.user.keyboard('10'); + await view.selectSectionAsync('hours'); + await view.user.keyboard('10'); + await view.selectSectionAsync('minutes'); + await view.user.keyboard('10'); - expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); + expect(onError.lastCall.args[0]).to.equal('partiallyFilledDate'); - await view.selectSectionAsync('meridiem'); - await view.user.keyboard('AM'); + await view.selectSectionAsync('meridiem'); + await view.user.keyboard('AM'); - expect(onError.lastCall.args[0]).not.to.equal('partiallyFilledDate'); - expect(onError.lastCall.args[0]).to.equal(null); - }); + expect(onError.lastCall.args[0]).not.to.equal('partiallyFilledDate'); + expect(onError.lastCall.args[0]).to.equal(null); + }); - it('should show field as invalid when partially filled', async () => { - const view = renderWithProps({ enableAccessibleFieldDOMStructure: true }); + it('should show field as invalid when partially filled', async () => { + const view = renderWithProps({ enableAccessibleFieldDOMStructure: true }); - await view.selectSectionAsync('hours'); - await view.user.keyboard('10'); + await view.selectSectionAsync('hours'); + await view.user.keyboard('10'); - expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'true'); - }); - }, -); + expect(getFieldInputRoot()).to.have.attribute('aria-invalid', 'true'); + }); +}); diff --git a/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.tsx b/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.tsx index 608b2d091ff13..0adc205481f0e 100644 --- a/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.tsx +++ b/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.tsx @@ -105,32 +105,23 @@ export const usePicker = < boolean | [boolean, boolean] >(false); - const setIsPartiallyFilled = useEventCallback((fieldId: string, isPartial: boolean | [boolean, boolean]) => { - // partiallyFilledMapRef.current.set(fieldId, isPartial); - // - // const values = Array.from(partiallyFilledMapRef.current.values()); - // - // if (values.length === 2) { - // setIsPartiallyFilledState([values[0], values[1]]); - // } else { - // setIsPartiallyFilledState(values[0] ?? false); - // } - - if (Array.isArray(isPartial)) { - setIsPartiallyFilledState(isPartial); - return; - } - - // Для одиночных полей (DateField) используем старую логику с Map - partiallyFilledMapRef.current.set(fieldId, isPartial); - const values = Array.from(partiallyFilledMapRef.current.values()); - - if (values.length === 2) { - setIsPartiallyFilledState([values[0], values[1]]); - } else { - setIsPartiallyFilledState(values[0] ?? false); - } - }); + const setIsPartiallyFilled = useEventCallback( + (fieldId: string, isPartial: boolean | [boolean, boolean]) => { + if (Array.isArray(isPartial)) { + setIsPartiallyFilledState(isPartial); + return; + } + + partiallyFilledMapRef.current.set(fieldId, isPartial); + const values = Array.from(partiallyFilledMapRef.current.values()); + + if (values.length === 2) { + setIsPartiallyFilledState([values[0], values[1]]); + } else { + setIsPartiallyFilledState(values[0] ?? false); + } + }, + ); const { timezone, state, setOpen, setValue, setValueFromView, value, viewValue } = useValueAndOpenStates({ diff --git a/packages/x-date-pickers/src/validation/useValidation.ts b/packages/x-date-pickers/src/validation/useValidation.ts index a371d3f55fbb6..ce591a7ced989 100644 --- a/packages/x-date-pickers/src/validation/useValidation.ts +++ b/packages/x-date-pickers/src/validation/useValidation.ts @@ -1,10 +1,10 @@ 'use client'; import * as React from 'react'; import useEventCallback from '@mui/utils/useEventCallback'; -import {MuiPickersAdapter, OnErrorProps, PickersTimezone} from '../models'; -import type {PickerValueManager} from '../internals/models'; -import {PickerValidValue} from '../internals/models'; -import {usePickerAdapter} from '../hooks'; +import { MuiPickersAdapter, OnErrorProps, PickersTimezone } from '../models'; +import type { PickerValueManager } from '../internals/models'; +import { PickerValidValue } from '../internals/models'; +import { usePickerAdapter } from '../hooks'; export type Validator = { (params: { @@ -12,7 +12,7 @@ export type Validator value: TValue; timezone: PickersTimezone; props: TValidationProps; - isPartiallyFilled: boolean | [boolean, boolean]; + isPartiallyFilled?: boolean | [boolean, boolean]; }): TError; valueManager: PickerValueManager; }; @@ -42,7 +42,7 @@ interface UseValidationOptions< * For example, the `validateTime` function supports `minTime`, `maxTime`, etc. */ props: TValidationProps; - isPartiallyFilled: boolean | [boolean, boolean]; + isPartiallyFilled?: boolean | [boolean, boolean]; } export interface UseValidationReturnValue { @@ -66,7 +66,7 @@ export interface UseValidationReturnValue TError; } @@ -85,7 +85,7 @@ export interface UseValidationReturnValue( options: UseValidationOptions, ): UseValidationReturnValue { - const { props, validator, value, timezone, onError, isPartiallyFilled } = options; + const { props, validator, value, timezone, onError, isPartiallyFilled = false } = options; const adapter = usePickerAdapter(); const previousValidationErrorRef = React.useRef( @@ -107,13 +107,13 @@ export function useValidation { + (newValue: TValue, newIsPartiallyFilled?: boolean | [boolean, boolean]) => { return validator({ adapter, value: newValue, timezone, props, - isPartiallyFilled: newIsPartiallyFilled, + isPartiallyFilled: newIsPartiallyFilled ?? false, }); }, ); From b8fe059e7eba4407c3121d3b687870017ee785b7 Mon Sep 17 00:00:00 2001 From: Anastasia Mastyaeva Date: Thu, 9 Apr 2026 11:13:10 +0300 Subject: [PATCH 4/4] fix(pickers): fix tests, ts errors --- ...lidation.MultiInputDateRangeField.test.tsx | 24 +++++++------------ .../useMultiInputRangeField.ts | 1 - .../useTextFieldProps.ts | 3 +-- .../partialFillValidation.DateField.test.tsx | 5 +--- ...rtialFillValidation.DateTimeField.test.tsx | 5 +--- .../partialFillValidation.TimeField.test.tsx | 5 +--- 6 files changed, 12 insertions(+), 31 deletions(-) diff --git a/packages/x-date-pickers-pro/src/MultiInputDateRangeField/tests/partialFillValidation.MultiInputDateRangeField.test.tsx b/packages/x-date-pickers-pro/src/MultiInputDateRangeField/tests/partialFillValidation.MultiInputDateRangeField.test.tsx index f85073347cd1f..899989a590fe9 100644 --- a/packages/x-date-pickers-pro/src/MultiInputDateRangeField/tests/partialFillValidation.MultiInputDateRangeField.test.tsx +++ b/packages/x-date-pickers-pro/src/MultiInputDateRangeField/tests/partialFillValidation.MultiInputDateRangeField.test.tsx @@ -9,12 +9,10 @@ describe(' - partiallyFilledDate validation', () => it('should call onError with partiallyFilledDate for start when start is partially filled', async () => { const onError = spy(); - const { user } = render( - , - ); + const { user } = render(); const months = screen.getAllByRole('spinbutton', { name: 'Month' }); - await user.click(months[0]); // start month + await user.click(months[0]); await user.keyboard('01'); expect(onError.callCount).to.equal(1); @@ -23,12 +21,10 @@ describe(' - partiallyFilledDate validation', () => it('should call onError with partiallyFilledDate for end when end is partially filled', async () => { const onError = spy(); - const { user } = render( - , - ); + const { user } = render(); const months = screen.getAllByRole('spinbutton', { name: 'Month' }); - await user.click(months[1]); // end month + await user.click(months[1]); await user.keyboard('01'); expect(onError.callCount).to.equal(1); @@ -37,9 +33,7 @@ describe(' - partiallyFilledDate validation', () => it('should call onError with null when partially filled start is fully cleared', async () => { const onError = spy(); - const { user } = render( - , - ); + const { user } = render(); const months = screen.getAllByRole('spinbutton', { name: 'Month' }); await user.click(months[0]); @@ -54,7 +48,7 @@ describe(' - partiallyFilledDate validation', () => }); it('should show start field as invalid when start is partially filled', async () => { - const { user } = render(); + const { user } = render(); const months = screen.getAllByRole('spinbutton', { name: 'Month' }); await user.click(months[0]); @@ -66,7 +60,7 @@ describe(' - partiallyFilledDate validation', () => }); it('should not show fields as invalid for empty fields', async () => { - render(); + render(); const roots = screen.getAllByRole('group'); expect(roots[0]).to.have.attribute('aria-invalid', 'false'); @@ -75,9 +69,7 @@ describe(' - partiallyFilledDate validation', () => it('should return invalidDate error when start is fully filled but date is invalid', async () => { const onError = spy(); - const { user } = render( - , - ); + const { user } = render(); const months = screen.getAllByRole('spinbutton', { name: 'Month' }); const days = screen.getAllByRole('spinbutton', { name: 'Day' }); diff --git a/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useMultiInputRangeField.ts b/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useMultiInputRangeField.ts index 0fbe535d40173..c51ecb2d8b2a1 100644 --- a/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useMultiInputRangeField.ts +++ b/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useMultiInputRangeField.ts @@ -1,7 +1,6 @@ 'use client'; import * as React from 'react'; import { - PickerManagerEnableAccessibleFieldDOMStructure, PickerManagerError, PickerManagerFieldInternalProps, useControlledValue, diff --git a/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useTextFieldProps.ts b/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useTextFieldProps.ts index a36e1bbc8b677..e8ac25754464e 100644 --- a/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useTextFieldProps.ts +++ b/packages/x-date-pickers-pro/src/hooks/useMultiInputRangeField/useTextFieldProps.ts @@ -32,9 +32,8 @@ export function useTextFieldProps< TForwardedProps extends UseTextFieldBaseForwardedProps, TError, >( - parameters: UseTextFieldPropsParameters, + parameters: UseTextFieldPropsParameters, ): UseMultiInputRangeFieldTextFieldProps { - const pickerContext = useNullablePickerContext(); const fieldPrivateContext = useNullableFieldPrivateContext(); const pickerPrivateContext = usePickerPrivateContext(); diff --git a/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx b/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx index 164c05f9903a4..dbbaff7d1e7b1 100644 --- a/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx +++ b/packages/x-date-pickers/src/DateField/tests/partialFillValidation.DateField.test.tsx @@ -6,7 +6,6 @@ describeAdapters('DateField - partiallyFilledDate validation', DateField, ({ ren it('should call onError with partiallyFilledDate when date is partially filled', async () => { const onError = spy(); const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, onError, }); @@ -20,7 +19,6 @@ describeAdapters('DateField - partiallyFilledDate validation', DateField, ({ ren it('should call onError with null when partially filled field is fully cleared', async () => { const onError = spy(); const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, onError, }); @@ -37,7 +35,6 @@ describeAdapters('DateField - partiallyFilledDate validation', DateField, ({ ren it('should return invalidDate error when all sections filled but date is invalid', async () => { const onError = spy(); const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, onError, }); @@ -56,7 +53,7 @@ describeAdapters('DateField - partiallyFilledDate validation', DateField, ({ ren }); it('should show field as invalid when partially filled', async () => { - const view = renderWithProps({ enableAccessibleFieldDOMStructure: true }); + const view = renderWithProps({}); await view.selectSectionAsync('month'); await view.user.keyboard('01'); diff --git a/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx b/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx index f9681af66f0a0..453d906f6b4c5 100644 --- a/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx +++ b/packages/x-date-pickers/src/DateTimeField/tests/partialFillValidation.DateTimeField.test.tsx @@ -9,7 +9,6 @@ describeAdapters( it('should call onError with partiallyFilledDate when date is partially filled', async () => { const onError = spy(); const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, onError, }); @@ -23,7 +22,6 @@ describeAdapters( it('should call onError with null when partially filled field is fully cleared', async () => { const onError = spy(); const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, onError, }); @@ -40,7 +38,6 @@ describeAdapters( it('should return invalidDate error when all sections filled but date is invalid', async () => { const onError = spy(); const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, onError, }); @@ -65,7 +62,7 @@ describeAdapters( }); it('should show field as invalid when partially filled', async () => { - const view = renderWithProps({ enableAccessibleFieldDOMStructure: true }); + const view = renderWithProps({}); await view.selectSectionAsync('month'); await view.user.keyboard('01'); diff --git a/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx b/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx index b380d084c9c0f..978955f1480dc 100644 --- a/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx +++ b/packages/x-date-pickers/src/TimeField/tests/partialFillValidation.TimeField.test.tsx @@ -6,7 +6,6 @@ describeAdapters('TimeField - partiallyFilledDate validation', TimeField, ({ ren it('should call onError with partiallyFilledDate when date is partially filled', async () => { const onError = spy(); const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, onError, }); @@ -20,7 +19,6 @@ describeAdapters('TimeField - partiallyFilledDate validation', TimeField, ({ ren it('should call onError with null when partially filled field is fully cleared', async () => { const onError = spy(); const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, onError, }); @@ -37,7 +35,6 @@ describeAdapters('TimeField - partiallyFilledDate validation', TimeField, ({ ren it('should return null error when all sections filled', async () => { const onError = spy(); const view = renderWithProps({ - enableAccessibleFieldDOMStructure: true, onError, }); @@ -56,7 +53,7 @@ describeAdapters('TimeField - partiallyFilledDate validation', TimeField, ({ ren }); it('should show field as invalid when partially filled', async () => { - const view = renderWithProps({ enableAccessibleFieldDOMStructure: true }); + const view = renderWithProps({}); await view.selectSectionAsync('hours'); await view.user.keyboard('10');