diff --git a/docs/pages/x/api/date-pickers/date-calendar.json b/docs/pages/x/api/date-pickers/date-calendar.json index ac153ad292e7..df66090022b5 100644 --- a/docs/pages/x/api/date-pickers/date-calendar.json +++ b/docs/pages/x/api/date-pickers/date-calendar.json @@ -50,8 +50,8 @@ "onChange": { "type": { "name": "func" }, "signature": { - "type": "function(value: TDate | null, selectionState: PickerSelectionState | undefined) => void", - "describedArgs": ["value", "selectionState"] + "type": "function(value: TValue, selectionState: PickerSelectionState | undefined, selectedView: TView | undefined) => void", + "describedArgs": ["value", "selectionState", "selectedView"] } }, "onFocusedViewChange": { diff --git a/docs/pages/x/api/date-pickers/digital-clock.json b/docs/pages/x/api/date-pickers/digital-clock.json index 8b486f29e71b..15a3ce9a8623 100644 --- a/docs/pages/x/api/date-pickers/digital-clock.json +++ b/docs/pages/x/api/date-pickers/digital-clock.json @@ -27,7 +27,7 @@ "onChange": { "type": { "name": "func" }, "signature": { - "type": "function(value: TDate | null, selectionState: PickerSelectionState | undefined, selectedView: TView | undefined) => void", + "type": "function(value: TValue, selectionState: PickerSelectionState | undefined, selectedView: TView | undefined) => void", "describedArgs": ["value", "selectionState", "selectedView"] } }, diff --git a/docs/pages/x/api/date-pickers/multi-section-digital-clock.json b/docs/pages/x/api/date-pickers/multi-section-digital-clock.json index 34266674966c..81169becb4d0 100644 --- a/docs/pages/x/api/date-pickers/multi-section-digital-clock.json +++ b/docs/pages/x/api/date-pickers/multi-section-digital-clock.json @@ -32,7 +32,7 @@ "onChange": { "type": { "name": "func" }, "signature": { - "type": "function(value: TDate | null, selectionState: PickerSelectionState | undefined, selectedView: TView | undefined) => void", + "type": "function(value: TValue, selectionState: PickerSelectionState | undefined, selectedView: TView | undefined) => void", "describedArgs": ["value", "selectionState", "selectedView"] } }, diff --git a/docs/pages/x/api/date-pickers/time-clock.json b/docs/pages/x/api/date-pickers/time-clock.json index 62a82204adbe..546139cd52f8 100644 --- a/docs/pages/x/api/date-pickers/time-clock.json +++ b/docs/pages/x/api/date-pickers/time-clock.json @@ -33,7 +33,7 @@ "onChange": { "type": { "name": "func" }, "signature": { - "type": "function(value: TDate | null, selectionState: PickerSelectionState | undefined, selectedView: TView | undefined) => void", + "type": "function(value: TValue, selectionState: PickerSelectionState | undefined, selectedView: TView | undefined) => void", "describedArgs": ["value", "selectionState", "selectedView"] } }, diff --git a/docs/translations/api-docs/date-pickers/date-calendar.json b/docs/translations/api-docs/date-pickers/date-calendar.json index 815986029479..8326182f0ea0 100644 --- a/docs/translations/api-docs/date-pickers/date-calendar.json +++ b/docs/translations/api-docs/date-pickers/date-calendar.json @@ -95,7 +95,8 @@ "deprecated": "", "typeDescriptions": { "value": "The new value.", - "selectionState": "Indicates if the date selection is complete." + "selectionState": "Indicates if the date selection is complete.", + "selectedView": "Indicates the view in which the selection has been made." } }, "onFocusedViewChange": { diff --git a/packages/x-date-pickers/src/DateCalendar/DateCalendar.tsx b/packages/x-date-pickers/src/DateCalendar/DateCalendar.tsx index 15142006766e..58419c757324 100644 --- a/packages/x-date-pickers/src/DateCalendar/DateCalendar.tsx +++ b/packages/x-date-pickers/src/DateCalendar/DateCalendar.tsx @@ -283,10 +283,14 @@ export const DateCalendar = React.forwardRef(function DateCalendar( const handleSelectedDayChange = useEventCallback((day: TDate | null) => { if (day) { // If there is a date already selected, then we want to keep its time - return handleValueChange(mergeDateAndTime(utils, day, value ?? referenceDate), 'finish'); + return handleValueChange( + mergeDateAndTime(utils, day, value ?? referenceDate), + 'finish', + view, + ); } - return handleValueChange(day, 'finish'); + return handleValueChange(day, 'finish', view); }); React.useEffect(() => { @@ -505,9 +509,11 @@ DateCalendar.propTypes = { monthsPerRow: PropTypes.oneOf([3, 4]), /** * Callback fired when the value changes. - * @template TDate - * @param {TDate | null} value The new value. + * @template TValue The value type. Will be either the same type as `value` or `null`. Can be in `[start, end]` format in case of range value. + * @template TView The view type. Will be one of date or time views. + * @param {TValue} value The new value. * @param {PickerSelectionState | undefined} selectionState Indicates if the date selection is complete. + * @param {TView | undefined} selectedView Indicates the view in which the selection has been made. */ onChange: PropTypes.func, /** diff --git a/packages/x-date-pickers/src/DateCalendar/DateCalendar.types.ts b/packages/x-date-pickers/src/DateCalendar/DateCalendar.types.ts index 177bc8beb3f8..dc1a96c9d1f5 100644 --- a/packages/x-date-pickers/src/DateCalendar/DateCalendar.types.ts +++ b/packages/x-date-pickers/src/DateCalendar/DateCalendar.types.ts @@ -20,7 +20,6 @@ import { MonthValidationProps, DayValidationProps, } from '../internals/models/validation'; -import { PickerSelectionState } from '../internals/hooks/usePicker/usePickerValue.types'; import { ExportedUseViewsOptions } from '../internals/hooks/useViews'; import { DateView, TimezoneProps } from '../models'; import { DefaultizedProps } from '../internals/models/helpers'; @@ -113,13 +112,6 @@ export interface DateCalendarProps * @default The closest valid date using the validation props, except callbacks such as `shouldDisableDate`. */ referenceDate?: TDate; - /** - * Callback fired when the value changes. - * @template TDate - * @param {TDate | null} value The new value. - * @param {PickerSelectionState | undefined} selectionState Indicates if the date selection is complete. - */ - onChange?: (value: TDate | null, selectionState?: PickerSelectionState) => void; className?: string; classes?: Partial; /** diff --git a/packages/x-date-pickers/src/DesktopDateTimePicker/tests/DesktopDateTimePicker.test.tsx b/packages/x-date-pickers/src/DesktopDateTimePicker/tests/DesktopDateTimePicker.test.tsx index b26c8bda0530..01e296ded858 100644 --- a/packages/x-date-pickers/src/DesktopDateTimePicker/tests/DesktopDateTimePicker.test.tsx +++ b/packages/x-date-pickers/src/DesktopDateTimePicker/tests/DesktopDateTimePicker.test.tsx @@ -38,7 +38,7 @@ describe('', () => { />, ); - openPicker({ type: 'date', variant: 'desktop' }); + openPicker({ type: 'date-time', variant: 'desktop' }); // Select year userEvent.mousePress(screen.getByRole('radio', { name: '2025' })); @@ -64,6 +64,48 @@ describe('', () => { }); }); + it('should allow selecting same view multiple times', () => { + const onChange = spy(); + const onAccept = spy(); + const onClose = spy(); + + render( + , + ); + + openPicker({ type: 'date-time', variant: 'desktop' }); + + // Change the date multiple times to check that picker doesn't close after cycling through all views internally + userEvent.mousePress(screen.getByRole('gridcell', { name: '2' })); + userEvent.mousePress(screen.getByRole('gridcell', { name: '3' })); + userEvent.mousePress(screen.getByRole('gridcell', { name: '4' })); + userEvent.mousePress(screen.getByRole('gridcell', { name: '5' })); + expect(onChange.callCount).to.equal(4); + expect(onAccept.callCount).to.equal(0); + expect(onClose.callCount).to.equal(0); + + // Change the hours + userEvent.mousePress(screen.getByRole('option', { name: '10 hours' })); + userEvent.mousePress(screen.getByRole('option', { name: '9 hours' })); + expect(onChange.callCount).to.equal(6); + expect(onAccept.callCount).to.equal(0); + expect(onClose.callCount).to.equal(0); + + // Change the minutes + userEvent.mousePress(screen.getByRole('option', { name: '50 minutes' })); + expect(onChange.callCount).to.equal(7); + // Change the meridiem + userEvent.mousePress(screen.getByRole('option', { name: 'PM' })); + expect(onChange.callCount).to.equal(8); + expect(onAccept.callCount).to.equal(1); + expect(onClose.callCount).to.equal(1); + }); + describe('prop: timeSteps', () => { it('should use "DigitalClock" view renderer, when "timeSteps.minutes" = 60', () => { const onChange = spy(); diff --git a/packages/x-date-pickers/src/DigitalClock/DigitalClock.tsx b/packages/x-date-pickers/src/DigitalClock/DigitalClock.tsx index a46ae7790b8b..a7bdc2197d07 100644 --- a/packages/x-date-pickers/src/DigitalClock/DigitalClock.tsx +++ b/packages/x-date-pickers/src/DigitalClock/DigitalClock.tsx @@ -403,8 +403,9 @@ DigitalClock.propTypes = { minutesStep: PropTypes.number, /** * Callback fired when the value changes. - * @template TDate, TView - * @param {TDate | null} value The new value. + * @template TValue The value type. Will be either the same type as `value` or `null`. Can be in `[start, end]` format in case of range value. + * @template TView The view type. Will be one of date or time views. + * @param {TValue} value The new value. * @param {PickerSelectionState | undefined} selectionState Indicates if the date selection is complete. * @param {TView | undefined} selectedView Indicates the view in which the selection has been made. */ diff --git a/packages/x-date-pickers/src/MultiSectionDigitalClock/MultiSectionDigitalClock.tsx b/packages/x-date-pickers/src/MultiSectionDigitalClock/MultiSectionDigitalClock.tsx index 61b914db46af..1de3ddb43ef0 100644 --- a/packages/x-date-pickers/src/MultiSectionDigitalClock/MultiSectionDigitalClock.tsx +++ b/packages/x-date-pickers/src/MultiSectionDigitalClock/MultiSectionDigitalClock.tsx @@ -151,7 +151,10 @@ export const MultiSectionDigitalClock = React.forwardRef(function MultiSectionDi return inViews.includes('meridiem') ? inViews : [...inViews, 'meridiem']; }, [ampm, inViews]); - const { view, setValueAndGoToView, focusedView } = useViews({ + const { view, setValueAndGoToNextView, focusedView } = useViews< + TDate | null, + TimeViewWithMeridiem + >({ view: inView, views, openTo, @@ -162,7 +165,7 @@ export const MultiSectionDigitalClock = React.forwardRef(function MultiSectionDi }); const handleMeridiemValueChange = useEventCallback((newValue: TDate | null) => { - setValueAndGoToView(newValue, null, 'meridiem'); + setValueAndGoToNextView(newValue, 'finish', 'meridiem'); }); const { meridiemMode, handleMeridiemChange } = useMeridiemMode( @@ -279,14 +282,6 @@ export const MultiSectionDigitalClock = React.forwardRef(function MultiSectionDi ], ); - const handleSectionChange = useEventCallback( - (sectionView: TimeViewWithMeridiem, newValue: TDate | null) => { - const viewIndex = views.indexOf(sectionView); - const nextView = views[viewIndex + 1]; - setValueAndGoToView(newValue, nextView, sectionView); - }, - ); - const buildViewProps = React.useCallback( (viewToBuild: TimeViewWithMeridiem): MultiSectionDigitalClockViewProps => { switch (viewToBuild) { @@ -294,7 +289,11 @@ export const MultiSectionDigitalClock = React.forwardRef(function MultiSectionDi return { onChange: (hours) => { const valueWithMeridiem = convertValueToMeridiem(hours, meridiemMode, ampm); - handleSectionChange('hours', utils.setHours(valueOrReferenceDate, valueWithMeridiem)); + setValueAndGoToNextView( + utils.setHours(valueOrReferenceDate, valueWithMeridiem), + 'finish', + 'hours', + ); }, items: getHourSectionOptions({ now, @@ -311,7 +310,11 @@ export const MultiSectionDigitalClock = React.forwardRef(function MultiSectionDi case 'minutes': { return { onChange: (minutes) => { - handleSectionChange('minutes', utils.setMinutes(valueOrReferenceDate, minutes)); + setValueAndGoToNextView( + utils.setMinutes(valueOrReferenceDate, minutes), + 'finish', + 'minutes', + ); }, items: getTimeSectionOptions({ value: utils.getMinutes(valueOrReferenceDate), @@ -328,7 +331,11 @@ export const MultiSectionDigitalClock = React.forwardRef(function MultiSectionDi case 'seconds': { return { onChange: (seconds) => { - handleSectionChange('seconds', utils.setSeconds(valueOrReferenceDate, seconds)); + setValueAndGoToNextView( + utils.setSeconds(valueOrReferenceDate, seconds), + 'finish', + 'seconds', + ); }, items: getTimeSectionOptions({ value: utils.getSeconds(valueOrReferenceDate), @@ -380,7 +387,7 @@ export const MultiSectionDigitalClock = React.forwardRef(function MultiSectionDi localeText.minutesClockNumberText, localeText.secondsClockNumberText, meridiemMode, - handleSectionChange, + setValueAndGoToNextView, valueOrReferenceDate, disabled, isTimeDisabled, @@ -504,8 +511,9 @@ MultiSectionDigitalClock.propTypes = { minutesStep: PropTypes.number, /** * Callback fired when the value changes. - * @template TDate, TView - * @param {TDate | null} value The new value. + * @template TValue The value type. Will be either the same type as `value` or `null`. Can be in `[start, end]` format in case of range value. + * @template TView The view type. Will be one of date or time views. + * @param {TValue} value The new value. * @param {PickerSelectionState | undefined} selectionState Indicates if the date selection is complete. * @param {TView | undefined} selectedView Indicates the view in which the selection has been made. */ diff --git a/packages/x-date-pickers/src/MultiSectionDigitalClock/MultiSectionDigitalClockSection.tsx b/packages/x-date-pickers/src/MultiSectionDigitalClock/MultiSectionDigitalClockSection.tsx index 1234a610504b..0713cc104395 100644 --- a/packages/x-date-pickers/src/MultiSectionDigitalClock/MultiSectionDigitalClockSection.tsx +++ b/packages/x-date-pickers/src/MultiSectionDigitalClock/MultiSectionDigitalClockSection.tsx @@ -127,7 +127,7 @@ export const MultiSectionDigitalClockSection = React.forwardRef( ) { const containerRef = React.useRef(null); const handleRef = useForkRef(ref, containerRef); - const previousSelected = React.useRef(null); + const previousActive = React.useRef(null); const props = useThemeProps({ props: inProps, @@ -160,21 +160,17 @@ export const MultiSectionDigitalClockSection = React.forwardRef( if (containerRef.current === null) { return; } - const selectedItem = containerRef.current.querySelector( + const activeItem = containerRef.current.querySelector( '[role="option"][aria-selected="true"]', ); - if (!selectedItem || previousSelected.current === selectedItem) { - // Handle setting the ref to null if the selected item is ever reset via UI - if (previousSelected.current !== selectedItem) { - previousSelected.current = selectedItem; - } - return; + if (active && autoFocus && activeItem) { + activeItem.focus(); } - previousSelected.current = selectedItem; - if (active && autoFocus) { - selectedItem.focus(); + if (!activeItem || previousActive.current === activeItem) { + return; } - const offsetTop = selectedItem.offsetTop; + previousActive.current = activeItem; + const offsetTop = activeItem.offsetTop; // Subtracting the 4px of extra margin intended for the first visible section item containerRef.current.scrollTop = offsetTop - 4; diff --git a/packages/x-date-pickers/src/TimeClock/TimeClock.tsx b/packages/x-date-pickers/src/TimeClock/TimeClock.tsx index 19cdccbf37b9..df9607f27e46 100644 --- a/packages/x-date-pickers/src/TimeClock/TimeClock.tsx +++ b/packages/x-date-pickers/src/TimeClock/TimeClock.tsx @@ -474,8 +474,9 @@ TimeClock.propTypes = { minutesStep: PropTypes.number, /** * Callback fired when the value changes. - * @template TDate, TView - * @param {TDate | null} value The new value. + * @template TValue The value type. Will be either the same type as `value` or `null`. Can be in `[start, end]` format in case of range value. + * @template TView The view type. Will be one of date or time views. + * @param {TValue} value The new value. * @param {PickerSelectionState | undefined} selectionState Indicates if the date selection is complete. * @param {TView | undefined} selectedView Indicates the view in which the selection has been made. */ diff --git a/packages/x-date-pickers/src/internals/hooks/useViews.tsx b/packages/x-date-pickers/src/internals/hooks/useViews.tsx index 6292375a348d..afd38cc1eded 100644 --- a/packages/x-date-pickers/src/internals/hooks/useViews.tsx +++ b/packages/x-date-pickers/src/internals/hooks/useViews.tsx @@ -13,7 +13,8 @@ export type PickerOnChangeFn = ( export interface UseViewsOptions { /** * Callback fired when the value changes. - * @template TValue + * @template TValue The value type. Will be either the same type as `value` or `null`. Can be in `[start, end]` format in case of range value. + * @template TView The view type. Will be one of date or time views. * @param {TValue} value The new value. * @param {PickerSelectionState | undefined} selectionState Indicates if the date selection is complete. * @param {TView | undefined} selectedView Indicates the view in which the selection has been made. @@ -62,7 +63,7 @@ export interface UseViewsOptions - extends MakeOptional, 'onChange'>, 'openTo' | 'views'> {} + extends MakeOptional, 'onChange' | 'openTo' | 'views'> {} let warnedOnceNotValidView = false; @@ -78,8 +79,8 @@ interface UseViewsResponse { setValueAndGoToNextView: ( value: TValue, currentViewSelectionState?: PickerSelectionState, + selectedView?: TView, ) => void; - setValueAndGoToView: (value: TValue, newView: TView | null, selectedView: TView) => void; } export function useViews({ @@ -162,21 +163,21 @@ export function useViews({ }); const handleChangeView = useEventCallback((newView: TView) => { + // always keep the focused view in sync + handleFocusedViewChange(newView, true); if (newView === view) { return; } setView(newView); - handleFocusedViewChange(newView, true); - if (onViewChange) { onViewChange(newView); } }); + const goToNextView = useEventCallback(() => { if (nextView) { handleChangeView(nextView); } - handleFocusedViewChange(nextView, true); }); const setValueAndGoToNextView = useEventCallback( @@ -190,23 +191,21 @@ export function useViews({ const globalSelectionState = isSelectionFinishedOnCurrentView && hasMoreViews ? 'partial' : currentViewSelectionState; - onChange(value, globalSelectionState); - if (isSelectionFinishedOnCurrentView) { + onChange(value, globalSelectionState, selectedView); + // Detects if the selected view is not the active one. + // Can happen if multiple views are displayed, like in `DesktopDateTimePicker` or `MultiSectionDigitalClock`. + if (selectedView && selectedView !== view) { + const nextViewAfterSelected = views[views.indexOf(selectedView) + 1]; + if (nextViewAfterSelected) { + // move to next view after the selected one + handleChangeView(nextViewAfterSelected); + } + } else if (isSelectionFinishedOnCurrentView) { goToNextView(); } }, ); - const setValueAndGoToView = useEventCallback( - (value: TValue, newView: TView | null, selectedView: TView) => { - onChange(value, newView ? 'partial' : 'finish', selectedView); - if (newView) { - handleChangeView(newView); - handleFocusedViewChange(newView, true); - } - }, - ); - return { view, setView: handleChangeView, @@ -218,6 +217,5 @@ export function useViews({ defaultView: views.includes(openTo!) ? openTo! : views[0], goToNextView, setValueAndGoToNextView, - setValueAndGoToView, }; } diff --git a/packages/x-date-pickers/src/internals/models/props/clock.ts b/packages/x-date-pickers/src/internals/models/props/clock.ts index 4fee82c002da..8c783c1ac3e6 100644 --- a/packages/x-date-pickers/src/internals/models/props/clock.ts +++ b/packages/x-date-pickers/src/internals/models/props/clock.ts @@ -1,6 +1,5 @@ import { SxProps, Theme } from '@mui/material/styles'; import { BaseTimeValidationProps, TimeValidationProps } from '../validation'; -import { PickerSelectionState } from '../../hooks/usePicker/usePickerValue.types'; import { TimeStepOptions, TimezoneProps } from '../../../models'; import type { ExportedDigitalClockProps } from '../../../DigitalClock/DigitalClock.types'; import type { ExportedMultiSectionDigitalClockProps } from '../../../MultiSectionDigitalClock/MultiSectionDigitalClock.types'; @@ -36,18 +35,6 @@ export interface BaseClockProps * Used when the component is not controlled. */ defaultValue?: TDate | null; - /** - * Callback fired when the value changes. - * @template TDate, TView - * @param {TDate | null} value The new value. - * @param {PickerSelectionState | undefined} selectionState Indicates if the date selection is complete. - * @param {TView | undefined} selectedView Indicates the view in which the selection has been made. - */ - onChange?: ( - value: TDate | null, - selectionState?: PickerSelectionState, - selectedView?: TView, - ) => void; /** * If `true`, the picker views and text field are disabled. * @default false diff --git a/test/e2e/index.test.ts b/test/e2e/index.test.ts index 56e738c8d607..b6666ff3f812 100644 --- a/test/e2e/index.test.ts +++ b/test/e2e/index.test.ts @@ -523,9 +523,9 @@ async function initializeEnvironment( ).to.equal('date'); await page.getByRole('gridcell', { name: '11' }).click(); - // assert that the tooltip closes after selection is complete + // assert that the dialog closes after selection is complete // could run into race condition otherwise - await page.waitForSelector('[role="tooltip"]', { state: 'detached' }); + await page.waitForSelector('[role="dialog"]', { state: 'detached' }); expect(await page.getByRole('textbox').inputValue()).to.equal('04/11/2022'); }); @@ -615,11 +615,61 @@ async function initializeEnvironment( await page.getByRole('option', { name: '30 minutes' }).click(); await page.getByRole('option', { name: 'PM' }).click(); - // assert that the tooltip closes after selection is complete + // assert that the dialog closes after selection is complete // could run into race condition otherwise - await page.waitForSelector('[role="tooltip"]', { state: 'detached' }); + await page.waitForSelector('[role="dialog"]', { state: 'detached' }); expect(await page.getByRole('textbox').inputValue()).to.equal('04/11/2022 03:30 PM'); }); + + it('should allow selecting same view multiple times with keyboard', async () => { + await renderFixture('DatePicker/BasicDesktopDateTimePicker'); + + await page.getByRole('button').click(); + + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Enter'); + + // move back to date calendar + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Enter'); + + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Enter'); + + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Enter'); + + // check that the picker has not been closed + await page.waitForSelector('[role="dialog"]', { state: 'visible' }); + + // Change the hours + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + + // check that the picker has not been closed + await page.waitForSelector('[role="dialog"]', { state: 'visible' }); + + // Change the minutes + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + + // Change the meridiem + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + + // assert that the dialog closes after selection is complete + // could run into race condition otherwise + await page.waitForSelector('[role="dialog"]', { state: 'detached' }); + expect(await page.getByRole('textbox').inputValue()).to.equal('04/21/2022 02:05 PM'); + }); + it('should correctly select hours section when there are no time renderers', async () => { await renderFixture('DatePicker/DesktopDateTimePickerNoTimeRenderers');